summaryrefslogtreecommitdiffstats
path: root/mobile/android/fenix/app/src/test/java/org/mozilla
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:34:42 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:34:42 +0000
commitda4c7e7ed675c3bf405668739c3012d140856109 (patch)
treecdd868dba063fecba609a1d819de271f0d51b23e /mobile/android/fenix/app/src/test/java/org/mozilla
parentAdding upstream version 125.0.3. (diff)
downloadfirefox-da4c7e7ed675c3bf405668739c3012d140856109.tar.xz
firefox-da4c7e7ed675c3bf405668739c3012d140856109.zip
Adding upstream version 126.0.upstream/126.0
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/fenix/app/src/test/java/org/mozilla')
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/AppRequestInterceptorTest.kt148
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/FenixApplicationTest.kt232
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/FenixLogSinkTest.kt102
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/HomeActivityTest.kt166
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/IntentReceiverActivityTest.kt322
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/MockNavHostActivity.kt48
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ReleaseChannelTest.kt34
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ServiceWorkerSupportTest.kt85
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/AddonDetailsBindingDelegateTest.kt233
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/AddonPermissionDetailsBindingDelegateTest.kt56
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/AddonsManagementViewTest.kt158
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/ExtensionsProcessDisabledBackgroundControllerTest.kt70
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/ExtensionsProcessDisabledForegroundControllerTest.kt154
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/InstalledAddonDetailsFragmentTest.kt260
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/bindings/BrowserStoreBindingTest.kt79
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/BaseBrowserFragmentTest.kt265
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/BrowserFragmentTest.kt448
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/FenixSnackbarDelegateTest.kt124
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/OpenInAppOnboardingObserverTest.kt202
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/StandardSnackbarErrorBindingTest.kt137
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/TranslationsBindingTest.kt257
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/browsingmode/DefaultBrowsingModeManagerTest.kt69
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/browsingmode/SimpleBrowsingModeManager.kt9
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/infobanner/DynamicInfoBannerBehaviorTest.kt69
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/infobanner/DynamicInfoBannerTest.kt56
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/readermode/DefaultReaderModeControllerTest.kt143
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/tabstrip/TabStripStateTest.kt293
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/CollectionCreationBottomBarViewTest.kt167
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/CollectionCreationFragmentTest.kt70
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/CollectionCreationStoreTest.kt235
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/CollectionCreationTabListAdapterTest.kt125
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/CollectionsListAdapterTest.kt74
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/DefaultCollectionCreationControllerTest.kt295
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/SaveCollectionListAdapterTest.kt84
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/TabDiffUtilTest.kt147
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/AccountAbnormalitiesTest.kt160
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/AppStoreTest.kt542
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/BackgroundServicesTest.kt153
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/ChangeDetectionMiddlewareTest.kt102
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/FenixSnackbarBehaviorTest.kt256
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/FindInPageIntegrationTest.kt255
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/IntentProcessorTypeTest.kt134
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/PermissionStorageTest.kt108
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/PrivateShortcutCreateManagerTest.kt89
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/ReviewPromptControllerTest.kt266
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/StoreProviderTest.kt91
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/TrackingProtectionPolicyFactoryTest.kt734
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/UrlRequestInterceptorTest.kt281
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/accounts/FenixAccountManagerTest.kt116
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/appstate/AppActionTest.kt27
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/appstate/AppStoreReducerTest.kt140
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/appstate/ShoppingActionTest.kt250
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/appstate/TabStripActionTest.kt35
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/bookmarks/BookmarksUseCaseTest.kt156
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/history/PagedHistoryProviderTest.kt496
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/menu/MenuNavigationMiddlewareTest.kt123
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/menu/MenuStoreTest.kt37
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/ActivationPingTest.kt39
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/BreadcrumbRecorderTest.kt80
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/DefaultMetricsStorageTest.kt505
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/FirstSessionPingTest.kt49
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/InstallReferrerMetricsServiceTest.kt188
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/MetricControllerTest.kt835
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/MetricsUtilsTest.kt103
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/MetricsUtilsTestRobolectric.kt273
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/settings/CounterPreferenceTest.kt64
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/settings/FeatureFlagPreferenceTest.kt68
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/settings/sitepermissions/ExtensionsTest.kt44
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/BrowserToolbarCFRPresenterTest.kt534
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/BrowserToolbarViewTest.kt287
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/DefaultBrowserToolbarControllerTest.kt485
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/DefaultBrowserToolbarInteractorTest.kt104
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/DefaultBrowserToolbarMenuControllerTest.kt878
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/DefaultToolbarIntegrationTest.kt72
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/EngineViewClippingBehaviorTest.kt292
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/MenuPresenterTest.kt83
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/NavbarIntegrationTest.kt45
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/TabCounterMenuTest.kt70
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/compose/LinkTextTest.kt76
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/compose/ext/IntTest.kt22
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/compose/ext/ModifierTest.kt33
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/crashes/CrashContentIntegrationTest.kt108
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/crashes/CrashContentViewTest.kt101
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/crashes/CrashReporterControllerTest.kt138
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/customtabs/CustomTabToolbarMenuTest.kt64
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/customtabs/ExternalAppBrowserActivityTest.kt239
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/customtabs/FennecWebAppIntentProcessorTest.kt70
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/customtabs/PoweredByNotificationTest.kt56
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/debugsettings/DebugDrawerNavigationMiddlewareTest.kt71
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/debugsettings/DebugDrawerStoreTest.kt42
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/debugsettings/DefaultDebugSettingsRepositoryTest.kt62
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/downloads/DynamicDownloadDialogBehaviorTest.kt258
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/downloads/DynamicDownloadDialogTest.kt37
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/downloads/FirstPartyDownloadDialogTest.kt115
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/downloads/StartDownloadDialogTest.kt223
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/downloads/ThirdPartyDownloadDialogTest.kt54
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/login/LoginExceptionFragmentStoreTest.kt30
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/login/LoginExceptionsAdapterTest.kt152
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/login/LoginExceptionsInteractorTest.kt41
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/login/LoginExceptionsViewTest.kt63
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsAdapterTest.kt152
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsFragmentStoreTest.kt29
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsInteractorTest.kt145
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsViewTest.kt89
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/viewholders/ExceptionsDeleteButtonViewHolderTest.kt46
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/viewholders/ExceptionsHeaderViewHolderTest.kt40
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/viewholders/ExceptionsListItemViewHolderTest.kt76
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/experiments/NimbusSetupKtTest.kt26
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ActivityTest.kt79
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/AppStateTest.kt609
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/AtomicIntegerTest.kt40
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/BrowserIconsTest.kt45
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/BrowserStateTest.kt383
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ConfigurationKtTest.kt34
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ConnectivityManagerTest.kt79
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ContextTest.kt169
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/DownloadItemKtTest.kt36
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/DrawableTest.kt46
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/FragmentTest.kt68
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ImageButtonTest.kt49
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ListTest.kt122
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/LogTest.kt75
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/MockKMatcherScope.kt58
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/NavControllerTest.kt52
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/SearchEngineTest.kt67
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/SharedPreferences.kt12
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/StringTest.kt26
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/TabCollectionTest.kt77
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/TopSiteTest.kt114
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/UriTest.kt56
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ViewTest.kt180
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/extension/WebExtensionPromptFeatureTest.kt328
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/helpers/FenixRobolectricTestApplication.kt45
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/helpers/FenixRobolectricTestRunner.kt35
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/helpers/LocaleTestRule.kt37
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/helpers/MockkRetryTestRule.kt65
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/helpers/StackTraces.kt39
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/helpers/perf/TestStrictModeManager.kt22
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/DefaultSessionControlControllerTest.kt1392
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/HomeFragmentTest.kt192
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/HomeMenuViewTest.kt255
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/PocketUpdatesMiddlewareTest.kt238
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/PrivateBrowsingButtonViewTest.kt73
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/RecentTabsListFeatureTest.kt390
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/SessionControlInteractorTest.kt307
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/TabCounterViewTest.kt174
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/blocklist/BlocklistHandlerTest.kt191
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/blocklist/BlocklistMiddlewareTest.kt462
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/AssistIntentProcessorTest.kt67
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/CrashReporterIntentProcessorTest.kt57
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/FennecBookmarkShortcutsIntentProcessorTest.kt76
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/HomeDeepLinkIntentProcessorTest.kt310
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/OpenBrowserIntentProcessorTest.kt60
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/OpenPasswordManagerIntentProcessorTest.kt77
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/OpenSpecificTabIntentProcessorTest.kt109
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/ReEngagementIntentProcessorTest.kt116
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/SpeechProcessingIntentProcessorTest.kt105
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/StartSearchIntentProcessorTest.kt81
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/pocket/DefaultPocketStoriesControllerTest.kt358
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/privatebrowsing/DefaultPrivateBrowsingControllerTest.kt158
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentbookmarks/DefaultRecentBookmarksControllerTest.kt153
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentbookmarks/RecentBookmarksFeatureTest.kt73
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentsyncedtabs/RecentSyncedTabFeatureTest.kt585
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentsyncedtabs/controller/DefaultRecentSyncedTabControllerTest.kt196
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recenttabs/controller/RecentTabControllerTest.kt146
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentvisits/HistoryMetadataMiddlewareTest.kt797
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentvisits/HistoryMetadataServiceTest.kt132
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentvisits/RecentVisitsFeatureTest.kt800
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentvisits/controller/RecentVisitsControllerTest.kt183
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentvisits/interactor/RecentVisitsInteractorTest.kt132
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentvisits/view/RecentBookmarksViewHolderTest.kt37
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapterTest.kt89
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/SessionControlViewTest.kt291
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/viewholders/NoCollectionsMessageViewHolderTest.kt89
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/toolbar/DefaultToolbarControllerTest.kt190
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/topsites/TopSiteItemViewHolderTest.kt155
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/topsites/TopSiteViewHolderTest.kt52
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/topsites/TopSitesAdapterTest.kt45
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/topsites/TopSitesPagerAdapterTest.kt177
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/intent/ExternalDeepLinkIntentProcessorTest.kt42
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkAdapterTest.kt171
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkControllerTest.kt564
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkDeselectNavigationListenerTest.kt91
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractorTest.kt281
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentStoreTest.kt260
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkItemMenuTest.kt147
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/DesktopFoldersTest.kt74
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/UtilsKtTest.kt116
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/viewholders/BookmarkNodeViewHolderTest.kt220
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadAdapterTest.kt56
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadControllerTest.kt116
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadFragmentStoreTest.kt86
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadFragmentTest.kt182
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadInteractorTest.kt86
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/HistoryDataSourceTest.kt207
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/HistoryFragmentStoreTest.kt235
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/HistoryItemTimeGroupTest.kt217
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/RemoveTimeFrameTest.kt58
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/state/HistoryNavigationMiddlewareTest.kt191
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/state/HistoryStorageMiddlewareTest.kt228
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/state/HistorySyncMiddlewareTest.kt53
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/state/HistoryTelemetryMiddlewareTest.kt124
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/state/bindings/MenuBindingTest.kt34
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/historymetadata/HistoryMetadataGroupFragmentStoreTest.kt134
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/historymetadata/controller/HistoryMetadataGroupControllerTest.kt340
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/historymetadata/view/HistoryMetadataGroupItemViewHolderTest.kt58
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/recentlyclosed/DefaultRecentlyClosedControllerTest.kt314
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedFragmentInteractorTest.kt45
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/lifecycle/StoreLifecycleObserverTest.kt52
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/menu/BrowserMenuSignInTest.kt93
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/messaging/DefaultMessageControllerTest.kt81
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/messaging/MessagingFeatureTest.kt30
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/messaging/state/MessagingMiddlewareTest.kt382
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/messaging/state/MessagingReducerTest.kt78
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/nimbus/NimbusBranchesControllerTest.kt204
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/nimbus/NimbusBranchesStoreTest.kt52
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/nimbus/NimbusSystemTest.kt164
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/onboarding/FenixOnboardingTest.kt71
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/onboarding/ReEngagementNotificationWorkerTest.kt45
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/onboarding/view/OnboardingMapperTest.kt156
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/onboarding/view/OnboardingPageUiDataTest.kt88
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/PerformanceInflaterTest.kt102
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/ProfilerMarkerFactProcessorTest.kt92
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/RunBlockingCounterTest.kt24
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StartupActivityLogTest.kt115
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StartupPathProviderTest.kt203
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StartupReportFullyDrawnTest.kt96
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StartupStateProviderTest.kt431
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StartupTimelineStateMachineTest.kt44
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StartupTypeTelemetryTest.kt150
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StorageStatsMetricsTest.kt62
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StrictModeManagerTest.kt133
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/ThreadPenaltyDeathWithIgnoresListenerTest.kt113
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/push/WebPushEngineIntegrationTest.kt227
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/SearchDialogControllerTest.kt648
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/SearchDialogFragmentTest.kt204
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/SearchDialogInteractorTest.kt128
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/SearchFragmentStoreTest.kt1288
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/awesomebar/AwesomeBarViewTest.kt1484
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/awesomebar/ShortcutsSuggestionProviderTest.kt113
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/toolbar/DefaultSearchSelectorControllerTest.kt71
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/toolbar/IncreasedTapAreaActionDecoratorTest.kt48
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/toolbar/SearchSelectorMenuTest.kt51
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/toolbar/SearchSelectorTest.kt40
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/toolbar/SearchSelectorToolbarActionTest.kt293
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/toolbar/ToolbarViewTest.kt890
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/session/PrivateNotificationServiceTest.kt80
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/CustomEtpCookiesOptionsDropDownListPreferenceTest.kt86
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/DropDownListPreferenceTest.kt52
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/ExtensionsTest.kt109
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/HomeSettingsFragmentTest.kt147
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/OnSharedPreferenceChangeListenerTest.kt57
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/PhoneFeatureTest.kt96
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/PreferenceBackedRadioButtonTest.kt147
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/SettingsFragmentTest.kt447
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/SupportUtilsTest.kt71
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/TrackingProtectionFragmentTest.kt63
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/about/AboutLibrariesFragmentTest.kt49
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/about/AboutPageAdapterTest.kt93
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/about/SecretDebugMenuTriggerTest.kt121
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/about/viewholders/AboutItemViewHolderTest.kt50
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/account/AccountSettingsFragmentStoreTest.kt46
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/account/AccountSettingsInteractorTest.kt96
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/account/AuthCustomTabActivityTest.kt55
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/account/DefaultSyncControllerTest.kt42
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/account/DefaultSyncInteractorTest.kt31
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/AddressEditorViewTest.kt346
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/controller/DefaultAddressEditorControllerTest.kt89
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/controller/DefaultAddressManagementControllerTest.kt58
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/ext/AddressTest.kt102
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/interactor/DefaultAddressEditorInteractorTest.kt50
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/interactor/DefaultAddressManagementInteractorTest.kt40
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/advanced/BaseLocaleViewHolderTest.kt59
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleManagerExtensionTest.kt86
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleSettingsControllerTest.kt179
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleSettingsInteractorTest.kt47
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleSettingsStoreTest.kt56
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleViewHoldersTest.kt110
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/autofill/AutofillFragmentStoreTest.kt49
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/autofill/AutofillSettingFragmentTest.kt227
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/biometric/BiometricPromptFeatureTest.kt126
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/biometric/ext/BiometricManagerKtTest.kt52
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardEditorStateTest.kt77
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardEditorViewTest.kt346
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardItemViewHolderTest.kt67
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardsAdapterTest.kt74
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardsManagementViewTest.kt61
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/DefaultCreditCardEditorControllerTest.kt134
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/DefaultCreditCardEditorInteractorTest.kt83
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/DefaultCreditCardsManagementControllerTest.kt71
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/DefaultCreditCardsManagementInteractorTest.kt56
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/StringTest.kt107
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/deletebrowsingdata/DefaultDeleteBrowsingDataControllerTest.kt122
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/deletebrowsingdata/DeleteAndQuitTest.kt149
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/AddLoginInteractorTest.kt51
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/EditLoginInteractorTest.kt34
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/LoginDetailInteractorTest.kt30
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/LoginDetailViewTest.kt65
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/LoginsFragmentStoreTest.kt237
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/LoginsListControllerTest.kt98
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/LoginsListViewHolderTest.kt75
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsInteractorTest.kt73
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsSortingStrategyMenuTest.kt85
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsStorageControllerTest.kt346
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/SyncPreferenceViewTest.kt210
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/AutoplayValueTest.kt385
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/ClearSiteDataViewTest.kt65
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/ConnectionDetailsInteractorTest.kt31
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/ConnectionDetailsViewTest.kt103
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/DefaultConnectionDetailsControllerTest.kt102
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/DefaultQuickSettingsControllerTest.kt444
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/ProtectionsViewTest.kt193
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsFragmentReducerTest.kt133
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsFragmentStoreTest.kt378
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsInteractorTest.kt103
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsSheetDialogFragmentTest.kt171
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/WebsiteInfoViewTest.kt73
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/WebsitePermissionViewTest.kt212
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/ext/PhoneFeatureExtKtTest.kt73
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/protections/cookiebanners/CookieBannerHandlingDetailsViewTest.kt255
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/protections/cookiebanners/DefaultCookieBannerDetailsControllerTest.kt262
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/protections/cookiebanners/DefaultCookieBannerDetailsInteractorTest.kt39
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/search/SearchEngineFragmentTest.kt133
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/search/SearchStringValidatorTest.kt104
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/sitepermissions/SitePermissionsDetailsExceptionsFragmentTest.kt109
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/sitepermissions/SitePermissionsManageExceptionsPhoneFeatureFragmentTest.kt544
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/sitepermissions/SitePermissionsWifiIntegrationTest.kt129
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/studies/DefaultStudiesInteractorTest.kt60
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/studies/StudiesAdapterTest.kt155
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/studies/StudiesViewTest.kt105
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/wallpaper/ExtensionsTest.kt205
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/SaveToPDFMiddlewareTest.kt425
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/ShareCloseViewTest.kt44
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/ShareControllerTest.kt699
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/ShareInteractorTest.kt84
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/ShareViewModelTest.kt219
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/listadapters/AccountDevicesShareAdapterTest.kt72
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/listadapters/AppShareAdapterTest.kt98
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/viewholders/AccountDeviceViewHolderTest.kt127
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/viewholders/AppViewHolderTest.kt68
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/ProductAnalysisTestData.kt68
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/ProductRecommendationTestData.kt58
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/ReviewQualityCheckBottomSheetStateFeatureTest.kt95
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/ReviewQualityCheckFeatureTest.kt557
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeNetworkChecker.kt13
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeReviewQualityCheckPreferences.kt27
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeReviewQualityCheckService.kt41
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeReviewQualityCheckTelemetryService.kt21
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeReviewQualityCheckVendorsService.kt21
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeShoppingExperienceFeature.kt19
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/DefaultReviewQualityCheckServiceTest.kt313
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/DefaultReviewQualityCheckVendorsServiceTest.kt204
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/EnumMapperTest.kt48
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/ProductAnalysisMapperTest.kt361
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/ProductRecommendationMapperTest.kt50
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/RetryKtTest.kt59
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckNavigationMiddlewareTest.kt56
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckTelemetryMiddlewareTest.kt512
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/store/ReviewQualityCheckStateTest.kt247
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/store/ReviewQualityCheckStoreTest.kt1630
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/ui/StarRatingKtTest.kt31
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shortcut/PwaOnboardingObserverTest.kt117
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/sync/SyncedTabsIntegrationTest.kt57
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/sync/ext/SyncedDeviceTabsTest.kt106
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabhistory/TabHistoryAdapterTest.kt114
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabhistory/TabHistoryControllerTest.kt61
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabhistory/TabHistoryInteractorTest.kt24
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabhistory/TabHistoryViewHolderTest.kt84
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/CloseOnLastTabBindingTest.kt114
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/DefaultTabsTrayControllerTest.kt1167
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/DefaultTabsTrayInteractorTest.kt163
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/FloatingActionButtonBindingTest.kt168
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/MenuIntegrationTest.kt82
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/NavigationInteractorTest.kt181
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/SecureTabsTrayBindingTest.kt123
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabCounterBindingTest.kt52
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabLayoutMediatorTest.kt107
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabLayoutObserverTest.kt69
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabSheetBehaviorManagerTest.kt897
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayDialogTest.kt24
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayFragmentTest.kt429
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayInfoBannerBindingTest.kt104
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayMiddlewareTest.kt75
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayStateTest.kt206
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayStoreReducerTest.kt75
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayStoreTest.kt140
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/AbstractBrowserTabViewHolderTest.kt190
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/AbstractBrowserTrayListTest.kt34
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/BrowserTabsAdapterTest.kt99
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/NormalTabsBindingTest.kt68
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/PrivateTabsBindingTest.kt69
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/SelectedItemAdapterBindingTest.kt57
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/SelectionMenuIntegrationTest.kt33
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/SwipeToDeleteBindingTest.kt47
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/TabSorterTest.kt124
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/TabsTouchHelperTest.kt65
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/ext/BrowserStoreKtTest.kt59
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/ext/ContextKtTest.kt39
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/ext/FenixSnackbarKtTest.kt130
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/ext/LongKtTest.kt25
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/ext/TabSessionStateKtTest.kt129
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/syncedtabs/SyncButtonBindingTest.kt53
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabsViewErrorTypeTest.kt40
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/viewholders/AbstractBrowserPageViewHolderTest.kt73
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/telemetry/TelemetryMiddlewareTest.kt579
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/toolbar/DefaultToolbarMenuTest.kt150
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/trackingprotection/ProtectionsStoreTest.kt191
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/trackingprotection/TrackerBucketsTest.kt111
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/trackingprotection/TrackingProtectionBlockingFragmentTest.kt79
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelDialogFragmentTest.kt137
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelInteractorTest.kt155
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelViewTest.kt217
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/translations/TranslationsDialogBindingTest.kt442
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/translations/TranslationsDialogMiddlewareTest.kt259
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/translations/TranslationsDialogReducerTest.kt254
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/translations/preferences/downloadlanguages/DownloadLanguagesFeatureTest.kt126
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/utils/ClipboardHandlerTest.kt107
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/utils/LocaleUtilsTest.kt32
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/utils/SettingsTest.kt1007
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/utils/ToolbarPopupWindowTest.kt68
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/utils/view/GroupableRadioButtonTest.kt66
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wallpapers/LegacyWallpaperMigrationTest.kt240
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wallpapers/WallpaperDownloaderTest.kt146
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wallpapers/WallpaperFileManagerTest.kt178
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wallpapers/WallpaperMetadataFetcherTest.kt414
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wallpapers/WallpaperTest.kt39
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wallpapers/WallpapersUseCasesTest.kt588
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/whatsnew/WhatsNewStorageTest.kt58
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/whatsnew/WhatsNewTest.kt94
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/whatsnew/WhatsNewVersionTest.kt19
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/widget/SearchWidgetProviderTest.kt190
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/widget/VoiceSearchActivityTest.kt186
-rw-r--r--mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wifi/WifiConnectionMonitorTest.kt190
433 files changed, 73109 insertions, 0 deletions
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/AppRequestInterceptorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/AppRequestInterceptorTest.kt
new file mode 100644
index 0000000000..6aac2eee2d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/AppRequestInterceptorTest.kt
@@ -0,0 +1,148 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix
+
+import android.net.ConnectivityManager
+import androidx.core.content.getSystemService
+import androidx.navigation.NavController
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import mozilla.components.browser.errorpages.ErrorPages
+import mozilla.components.browser.errorpages.ErrorType
+import mozilla.components.concept.engine.request.RequestInterceptor
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.telemetry.glean.testing.GleanTestRule
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.AppRequestInterceptor.Companion.HIGH_RISK_ERROR_PAGES
+import org.mozilla.fenix.AppRequestInterceptor.Companion.LOW_AND_MEDIUM_RISK_ERROR_PAGES
+import org.mozilla.fenix.GleanMetrics.ErrorPage
+import org.mozilla.fenix.ext.isOnline
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class AppRequestInterceptorTest {
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ private lateinit var interceptor: RequestInterceptor
+ private lateinit var navigationController: NavController
+
+ @Before
+ fun setUp() {
+ mockkStatic("org.mozilla.fenix.ext.ConnectivityManagerKt")
+
+ every { testContext.getSystemService<ConnectivityManager>()!!.isOnline() } returns true
+ every { testContext.settings() } returns mockk(relaxed = true)
+
+ navigationController = mockk(relaxed = true)
+ interceptor = AppRequestInterceptor(testContext).also {
+ it.setNavigationController(navigationController)
+ }
+ }
+
+ @Test
+ fun `onErrorRequest results in correct error page for low risk level error`() {
+ setOf(
+ ErrorType.UNKNOWN,
+ ErrorType.ERROR_NET_INTERRUPT,
+ ErrorType.ERROR_NET_TIMEOUT,
+ ErrorType.ERROR_CONNECTION_REFUSED,
+ ErrorType.ERROR_UNKNOWN_SOCKET_TYPE,
+ ErrorType.ERROR_REDIRECT_LOOP,
+ ErrorType.ERROR_OFFLINE,
+ ErrorType.ERROR_NET_RESET,
+ ErrorType.ERROR_UNSAFE_CONTENT_TYPE,
+ ErrorType.ERROR_CORRUPTED_CONTENT,
+ ErrorType.ERROR_CONTENT_CRASHED,
+ ErrorType.ERROR_INVALID_CONTENT_ENCODING,
+ ErrorType.ERROR_UNKNOWN_HOST,
+ ErrorType.ERROR_MALFORMED_URI,
+ ErrorType.ERROR_FILE_NOT_FOUND,
+ ErrorType.ERROR_FILE_ACCESS_DENIED,
+ ErrorType.ERROR_PROXY_CONNECTION_REFUSED,
+ ErrorType.ERROR_UNKNOWN_PROXY_HOST,
+ ErrorType.ERROR_UNKNOWN_PROTOCOL,
+ ).forEach { error ->
+ val actualPage = createActualErrorPage(error)
+ val expectedPage = createExpectedErrorPage(
+ error = error,
+ html = LOW_AND_MEDIUM_RISK_ERROR_PAGES,
+ )
+
+ assertEquals(expectedPage, actualPage)
+ // Check if the error metric was recorded
+ assertEquals(
+ error.name,
+ ErrorPage.visitedError.testGetValue()!!.last().extra?.get("error_type"),
+ )
+ }
+ }
+
+ @Test
+ fun `onErrorRequest results in correct error page for medium risk level error`() {
+ setOf(
+ ErrorType.ERROR_SECURITY_BAD_CERT,
+ ErrorType.ERROR_SECURITY_SSL,
+ ErrorType.ERROR_PORT_BLOCKED,
+ ).forEach { error ->
+ val actualPage = createActualErrorPage(error)
+ val expectedPage = createExpectedErrorPage(
+ error = error,
+ html = LOW_AND_MEDIUM_RISK_ERROR_PAGES,
+ )
+
+ assertEquals(expectedPage, actualPage)
+ // Check if the error metric was recorded
+ assertEquals(
+ error.name,
+ ErrorPage.visitedError.testGetValue()!!.last().extra?.get("error_type"),
+ )
+ }
+ }
+
+ @Test
+ fun `onErrorRequest results in correct error page for high risk level error`() {
+ setOf(
+ ErrorType.ERROR_SAFEBROWSING_HARMFUL_URI,
+ ErrorType.ERROR_SAFEBROWSING_MALWARE_URI,
+ ErrorType.ERROR_SAFEBROWSING_PHISHING_URI,
+ ErrorType.ERROR_SAFEBROWSING_UNWANTED_URI,
+ ).forEach { error ->
+ val actualPage = createActualErrorPage(error)
+ val expectedPage = createExpectedErrorPage(
+ error = error,
+ html = HIGH_RISK_ERROR_PAGES,
+ )
+
+ assertEquals(expectedPage, actualPage)
+ // Check if the error metric was recorded
+ assertEquals(
+ error.name,
+ ErrorPage.visitedError.testGetValue()!!.last().extra?.get("error_type"),
+ )
+ }
+ }
+
+ private fun createActualErrorPage(error: ErrorType): String {
+ val errorPage = interceptor.onErrorRequest(session = mockk(), errorType = error, uri = null)
+ as RequestInterceptor.ErrorResponse
+ return errorPage.uri
+ }
+
+ private fun createExpectedErrorPage(error: ErrorType, html: String): String {
+ return ErrorPages.createUrlEncodedErrorPage(
+ context = testContext,
+ errorType = error,
+ htmlResource = html,
+ )
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/FenixApplicationTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/FenixApplicationTest.kt
new file mode 100644
index 0000000000..b758f6d79c
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/FenixApplicationTest.kt
@@ -0,0 +1,232 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix
+
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.test.core.app.ApplicationProvider
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.webextension.DisabledFlags
+import mozilla.components.concept.engine.webextension.Metadata
+import mozilla.components.concept.engine.webextension.WebExtension
+import mozilla.components.feature.addons.migration.DefaultSupportedAddonsChecker
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.utils.BrowsersCache
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.Addons
+import org.mozilla.fenix.GleanMetrics.Metrics
+import org.mozilla.fenix.GleanMetrics.Preferences
+import org.mozilla.fenix.GleanMetrics.SearchDefaultEngine
+import org.mozilla.fenix.GleanMetrics.TabStrip
+import org.mozilla.fenix.GleanMetrics.TopSites
+import org.mozilla.fenix.components.metrics.MozillaProductDetector
+import org.mozilla.fenix.components.toolbar.ToolbarPosition
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.utils.Settings
+import org.robolectric.annotation.Config
+
+@RunWith(FenixRobolectricTestRunner::class)
+class FenixApplicationTest {
+
+ @get:Rule val gleanTestRule = GleanTestRule(ApplicationProvider.getApplicationContext())
+
+ private lateinit var application: FenixApplication
+ private lateinit var browsersCache: BrowsersCache
+ private lateinit var mozillaProductDetector: MozillaProductDetector
+ private lateinit var browserStore: BrowserStore
+
+ @Before
+ fun setUp() {
+ application = ApplicationProvider.getApplicationContext()
+ browsersCache = mockk(relaxed = true)
+ mozillaProductDetector = mockk(relaxed = true)
+ browserStore = BrowserStore()
+ }
+
+ @Test
+ fun `GIVEN there are unsupported addons installed WHEN subscribing for new add-ons checks THEN register for checks`() {
+ val checker = mockk<DefaultSupportedAddonsChecker>(relaxed = true)
+ val unSupportedExtension: WebExtension = mockk()
+ val metadata: Metadata = mockk()
+
+ every { unSupportedExtension.getMetadata() } returns metadata
+ every { metadata.disabledFlags } returns DisabledFlags.select(DisabledFlags.APP_SUPPORT)
+
+ application.subscribeForNewAddonsIfNeeded(checker, listOf(unSupportedExtension))
+
+ verify { checker.registerForChecks() }
+ }
+
+ @Test
+ fun `GIVEN there are no unsupported addons installed WHEN subscribing for new add-ons checks THEN unregister for checks`() {
+ val checker = mockk<DefaultSupportedAddonsChecker>(relaxed = true)
+ val unSupportedExtension: WebExtension = mockk()
+ val metadata: Metadata = mockk()
+
+ every { unSupportedExtension.getMetadata() } returns metadata
+ every { metadata.disabledFlags } returns DisabledFlags.select(DisabledFlags.USER)
+
+ application.subscribeForNewAddonsIfNeeded(checker, listOf(unSupportedExtension))
+
+ verify { checker.unregisterForChecks() }
+ }
+
+ @Test
+ @Config(sdk = [Build.VERSION_CODES.O])
+ fun `WHEN setStartupMetrics is called THEN sets some base metrics`() {
+ val expectedAppName = "org.mozilla.fenix"
+ val expectedAppInstallSource = "org.mozilla.install.source"
+ val settings = spyk(Settings(testContext))
+ val application = spyk(application)
+ val packageManager: PackageManager = mockk()
+
+ every { application.packageManager } returns packageManager
+ @Suppress("DEPRECATION")
+ every { packageManager.getInstallerPackageName(any()) } returns expectedAppInstallSource
+ every { browsersCache.all(any()).isDefaultBrowser } returns true
+ every { mozillaProductDetector.getMozillaBrowserDefault(any()) } returns expectedAppName
+ every { mozillaProductDetector.getInstalledMozillaProducts(any()) } returns listOf(expectedAppName)
+ every { settings.adjustCampaignId } returns "ID"
+ every { settings.adjustAdGroup } returns "group"
+ every { settings.adjustCreative } returns "creative"
+ every { settings.adjustNetwork } returns "network"
+ // Testing [settings.migrateSearchWidgetInstalledPrefIfNeeded]
+ settings.preferences.edit().putInt("pref_key_search_widget_installed", 5).apply()
+ every { settings.openTabsCount } returns 1
+ every { settings.topSitesSize } returns 2
+ every { settings.installedAddonsCount } returns 3
+ every { settings.installedAddonsList } returns "test1,test2,test3"
+ every { settings.enabledAddonsCount } returns 2
+ every { settings.enabledAddonsList } returns "test1,test2"
+ every { settings.desktopBookmarksSize } returns 4
+ every { settings.mobileBookmarksSize } returns 5
+ every { settings.toolbarPosition } returns ToolbarPosition.BOTTOM
+ every { settings.getTabViewPingString() } returns "test"
+ every { settings.getTabTimeoutPingString() } returns "test"
+ every { settings.shouldShowSearchSuggestions } returns true
+ every { settings.shouldUseTrackingProtection } returns true
+ every { settings.isRemoteDebuggingEnabled } returns true
+ every { settings.isTelemetryEnabled } returns true
+ every { settings.isExperimentationEnabled } returns true
+ every { settings.shouldShowHistorySuggestions } returns true
+ every { settings.shouldShowBookmarkSuggestions } returns true
+ every { settings.shouldShowClipboardSuggestions } returns true
+ every { settings.shouldShowSearchShortcuts } returns true
+ every { settings.openLinksInAPrivateTab } returns true
+ every { settings.shouldShowSearchSuggestionsInPrivate } returns true
+ every { settings.shouldShowVoiceSearch } returns true
+ every { settings.openLinksInExternalApp } returns "never"
+ every { settings.shouldUseFixedTopToolbar } returns true
+ every { settings.useStandardTrackingProtection } returns true
+ every { settings.switchServiceIsEnabled } returns true
+ every { settings.touchExplorationIsEnabled } returns true
+ every { settings.shouldUseLightTheme } returns true
+ every { settings.signedInFxaAccount } returns true
+ every { settings.showRecentTabsFeature } returns true
+ every { settings.showRecentBookmarksFeature } returns true
+ every { settings.showTopSitesFeature } returns true
+ every { settings.historyMetadataUIFeature } returns true
+ every { settings.showPocketRecommendationsFeature } returns true
+ every { settings.showContileFeature } returns true
+ every { application.reportHomeScreenMetrics(settings) } just Runs
+ every { application.getDeviceTotalRAM() } returns 7L
+ every { settings.inactiveTabsAreEnabled } returns true
+ every { application.isDeviceRamAboveThreshold } returns true
+ every { settings.isTabletAndTabStripEnabled } returns true
+
+ assertTrue(settings.contileContextId.isEmpty())
+ assertNull(TopSites.contextId.testGetValue())
+
+ application.setStartupMetrics(
+ browserStore = browserStore,
+ settings = settings,
+ browsersCache = browsersCache,
+ mozillaProductDetector = mozillaProductDetector,
+ )
+
+ // Verify that browser defaults metrics are set.
+ assertEquals("Mozilla", Metrics.distributionId.testGetValue())
+ assertEquals(true, Metrics.defaultBrowser.testGetValue())
+ assertEquals(expectedAppName, Metrics.defaultMozBrowser.testGetValue())
+ assertEquals(listOf(expectedAppName), Metrics.mozillaProducts.testGetValue())
+ assertEquals("ID", Metrics.adjustCampaign.testGetValue())
+ assertEquals("group", Metrics.adjustAdGroup.testGetValue())
+ assertEquals("creative", Metrics.adjustCreative.testGetValue())
+ assertEquals("network", Metrics.adjustNetwork.testGetValue())
+ assertEquals(true, Metrics.searchWidgetInstalled.testGetValue())
+ assertEquals(true, Metrics.hasOpenTabs.testGetValue())
+ assertEquals(1, Metrics.tabsOpenCount.testGetValue())
+ assertEquals(true, Metrics.hasTopSites.testGetValue())
+ assertEquals(2, Metrics.topSitesCount.testGetValue())
+ assertEquals(true, Addons.hasInstalledAddons.testGetValue())
+ assertEquals(listOf("test1", "test2", "test3"), Addons.installedAddons.testGetValue())
+ assertEquals(true, Addons.hasEnabledAddons.testGetValue())
+ assertEquals(listOf("test1", "test2"), Addons.enabledAddons.testGetValue())
+ assertEquals(true, Preferences.searchSuggestionsEnabled.testGetValue())
+ assertEquals(true, Preferences.remoteDebuggingEnabled.testGetValue())
+ assertEquals(true, Preferences.telemetryEnabled.testGetValue())
+ assertEquals(true, Preferences.studiesEnabled.testGetValue())
+ assertEquals(true, Preferences.browsingHistorySuggestion.testGetValue())
+ assertEquals(true, Preferences.bookmarksSuggestion.testGetValue())
+ assertEquals(true, Preferences.clipboardSuggestionsEnabled.testGetValue())
+ assertEquals(true, Preferences.searchShortcutsEnabled.testGetValue())
+ assertEquals(true, Preferences.voiceSearchEnabled.testGetValue())
+ assertEquals("never", Preferences.openLinksInAppEnabled.testGetValue())
+ assertEquals(true, Preferences.signedInSync.testGetValue())
+ assertEquals(emptyList<String>(), Preferences.syncItems.testGetValue())
+ assertEquals("fixed_top", Preferences.toolbarPositionSetting.testGetValue())
+ assertEquals("standard", Preferences.enhancedTrackingProtection.testGetValue())
+ assertEquals(listOf("switch", "touch exploration"), Preferences.accessibilityServices.testGetValue())
+ assertEquals(true, Preferences.inactiveTabsEnabled.testGetValue())
+ assertEquals(expectedAppInstallSource, Metrics.installSource.testGetValue())
+ assertEquals(true, Metrics.defaultWallpaper.testGetValue())
+ assertEquals(true, Metrics.ramMoreThanThreshold.testGetValue())
+ assertEquals(7L, Metrics.deviceTotalRam.testGetValue())
+ assertEquals(true, TabStrip.enabled.testGetValue())
+
+ val contextId = TopSites.contextId.testGetValue()!!.toString()
+
+ assertNotNull(TopSites.contextId.testGetValue())
+ assertEquals(contextId, settings.contileContextId)
+
+ // Verify that search engine defaults are NOT set. This test does
+ // not mock most of the objects telemetry is collected from.
+ assertNull(SearchDefaultEngine.code.testGetValue())
+ assertNull(SearchDefaultEngine.name.testGetValue())
+ assertNull(SearchDefaultEngine.searchUrl.testGetValue())
+
+ application.setStartupMetrics(browserStore, settings, browsersCache, mozillaProductDetector)
+
+ assertEquals(contextId, TopSites.contextId.testGetValue()!!.toString())
+ assertEquals(contextId, settings.contileContextId)
+ }
+
+ @Test
+ fun `GIVEN the current etp mode is custom WHEN tracking the etp metric THEN track also the cookies option`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { shouldUseTrackingProtection } returns true
+ every { useCustomTrackingProtection } returns true
+ every { blockCookiesSelectionInCustomTrackingProtection } returns "Test"
+ }
+
+ application.setStartupMetrics(browserStore, settings, browsersCache, mozillaProductDetector)
+
+ assertEquals("Test", Preferences.etpCustomCookiesSelection.testGetValue())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/FenixLogSinkTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/FenixLogSinkTest.kt
new file mode 100644
index 0000000000..a084f9fd8d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/FenixLogSinkTest.kt
@@ -0,0 +1,102 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix
+
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.support.base.log.sink.AndroidLogSink
+import org.junit.Before
+import org.junit.Test
+
+class FenixLogSinkTest {
+
+ private lateinit var androidLogSink: AndroidLogSink
+
+ @Before
+ fun setup() {
+ androidLogSink = spyk(AndroidLogSink())
+ }
+
+ @Test
+ fun `GIVEN we're in a release build WHEN we log debug statements THEN logs should not be forwarded`() {
+ val logSink = FenixLogSink(false, androidLogSink)
+ logSink.log(
+ mozilla.components.support.base.log.Log.Priority.DEBUG,
+ "test",
+ message = "test",
+ )
+ verify(exactly = 0) { androidLogSink.log(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `GIVEN we're in a release build WHEN we log error statements THEN logs should be forwarded`() {
+ val logSink = FenixLogSink(false, androidLogSink)
+ logSink.log(
+ mozilla.components.support.base.log.Log.Priority.ERROR,
+ "test",
+ message = "test",
+ )
+
+ verify(exactly = 1) {
+ androidLogSink.log(
+ mozilla.components.support.base.log.Log.Priority.ERROR,
+ "test",
+ message = "test",
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN we're in a release build WHEN we log warn statements THEN logs should be forwarded`() {
+ val logSink = FenixLogSink(false, androidLogSink)
+ logSink.log(
+ mozilla.components.support.base.log.Log.Priority.WARN,
+ "test",
+ message = "test",
+ )
+ verify(exactly = 1) {
+ androidLogSink.log(
+ mozilla.components.support.base.log.Log.Priority.WARN,
+ "test",
+ message = "test",
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN we're in a release build WHEN we log info statements THEN logs should be forwarded`() {
+ val logSink = FenixLogSink(false, androidLogSink)
+ logSink.log(
+ mozilla.components.support.base.log.Log.Priority.INFO,
+ "test",
+ message = "test",
+ )
+ verify(exactly = 1) {
+ androidLogSink.log(
+ mozilla.components.support.base.log.Log.Priority.INFO,
+ "test",
+ message = "test",
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN we're in a debug build WHEN we log debug statements THEN logs should be forwarded`() {
+ val logSink = FenixLogSink(true, androidLogSink)
+ logSink.log(
+ mozilla.components.support.base.log.Log.Priority.DEBUG,
+ "test",
+ message = "test",
+ )
+
+ verify(exactly = 1) {
+ androidLogSink.log(
+ mozilla.components.support.base.log.Log.Priority.DEBUG,
+ "test",
+ message = "test",
+ )
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/HomeActivityTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/HomeActivityTest.kt
new file mode 100644
index 0000000000..cb6cff486a
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/HomeActivityTest.kt
@@ -0,0 +1,166 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix
+
+import android.content.Intent
+import android.os.Bundle
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.utils.toSafeIntent
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.HomeActivity.Companion.PRIVATE_BROWSING_MODE
+import org.mozilla.fenix.browser.browsingmode.BrowsingMode
+import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.getIntentSource
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.helpers.perf.TestStrictModeManager
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class HomeActivityTest {
+
+ private lateinit var activity: HomeActivity
+
+ @Before
+ fun setup() {
+ activity = spyk(HomeActivity())
+ }
+
+ @Test
+ fun getIntentSource() {
+ val launcherIntent = Intent(Intent.ACTION_MAIN).apply {
+ addCategory(Intent.CATEGORY_LAUNCHER)
+ }.toSafeIntent()
+ assertEquals("APP_ICON", activity.getIntentSource(launcherIntent))
+
+ val viewIntent = Intent(Intent.ACTION_VIEW).toSafeIntent()
+ assertEquals("LINK", activity.getIntentSource(viewIntent))
+
+ val otherIntent = Intent().toSafeIntent()
+ assertNull(activity.getIntentSource(otherIntent))
+ }
+
+ @Test
+ fun `getModeFromIntentOrLastKnown returns mode from settings when intent does not set`() {
+ every { testContext.settings() } returns Settings(testContext)
+ every { activity.applicationContext } returns testContext
+ testContext.settings().lastKnownMode = BrowsingMode.Private
+
+ assertEquals(testContext.settings().lastKnownMode, activity.getModeFromIntentOrLastKnown(null))
+ }
+
+ @Test
+ fun `getModeFromIntentOrLastKnown returns mode from intent when set`() {
+ every { testContext.settings() } returns Settings(testContext)
+ testContext.settings().lastKnownMode = BrowsingMode.Normal
+
+ val intent = Intent()
+ intent.putExtra(PRIVATE_BROWSING_MODE, true)
+
+ assertNotEquals(testContext.settings().lastKnownMode, activity.getModeFromIntentOrLastKnown(intent))
+ assertEquals(BrowsingMode.Private, activity.getModeFromIntentOrLastKnown(intent))
+ }
+
+ @Test
+ fun `isActivityColdStarted returns true for null savedInstanceState and not launched from history`() {
+ assertTrue(activity.isActivityColdStarted(Intent(), null))
+ }
+
+ @Test
+ fun `isActivityColdStarted returns false for valid savedInstanceState and not launched from history`() {
+ assertFalse(activity.isActivityColdStarted(Intent(), Bundle()))
+ }
+
+ @Test
+ fun `isActivityColdStarted returns false for null savedInstanceState and launched from history`() {
+ val startingIntent = Intent().apply {
+ flags = flags or Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY
+ }
+
+ assertFalse(activity.isActivityColdStarted(startingIntent, null))
+ }
+
+ @Test
+ fun `navigateToBrowserOnColdStart in normal mode navigates to browser`() {
+ val browsingModeManager: BrowsingModeManager = mockk()
+ every { browsingModeManager.mode } returns BrowsingMode.Normal
+
+ val settings: Settings = mockk()
+ every { settings.shouldReturnToBrowser } returns true
+ every { activity.components.settings.shouldReturnToBrowser } returns true
+ every { activity.openToBrowser(any(), any()) } returns Unit
+
+ activity.browsingModeManager = browsingModeManager
+ activity.navigateToBrowserOnColdStart()
+
+ verify(exactly = 1) { activity.openToBrowser(BrowserDirection.FromGlobal, null) }
+ }
+
+ @Test
+ fun `navigateToBrowserOnColdStart in private mode does not navigate to browser`() {
+ val browsingModeManager: BrowsingModeManager = mockk()
+ every { browsingModeManager.mode } returns BrowsingMode.Private
+
+ val settings: Settings = mockk()
+ every { settings.shouldReturnToBrowser } returns true
+ every { activity.components.settings.shouldReturnToBrowser } returns true
+ every { activity.openToBrowser(any(), any()) } returns Unit
+
+ activity.browsingModeManager = browsingModeManager
+ activity.navigateToBrowserOnColdStart()
+
+ verify(exactly = 0) { activity.openToBrowser(BrowserDirection.FromGlobal, null) }
+ }
+
+ @Test
+ fun `isActivityColdStarted returns false for null savedInstanceState and not launched from history`() {
+ val startingIntent = Intent().apply {
+ flags = flags or Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY
+ }
+
+ assertFalse(activity.isActivityColdStarted(startingIntent, Bundle()))
+ }
+
+ @Test
+ fun `GIVEN the user has been away for a long time WHEN the user opens the app THEN do start on home`() {
+ every { testContext.components.strictMode } returns TestStrictModeManager()
+ val settings: Settings = mockk()
+ val startingIntent = Intent().apply {
+ action = Intent.ACTION_MAIN
+ }
+ every { activity.applicationContext } returns testContext
+
+ every { settings.shouldStartOnHome() } returns true
+ every { activity.getSettings() } returns settings
+
+ assertTrue(activity.shouldStartOnHome(startingIntent))
+ }
+
+ @Test
+ fun `GIVEN the user has been away for a long time WHEN opening a link THEN do not start on home`() {
+ every { testContext.components.strictMode } returns TestStrictModeManager()
+ val settings: Settings = mockk()
+ val startingIntent = Intent().apply {
+ action = Intent.ACTION_VIEW
+ }
+ every { settings.shouldStartOnHome() } returns true
+ every { activity.getSettings() } returns settings
+ every { activity.applicationContext } returns testContext
+
+ assertFalse(activity.shouldStartOnHome(startingIntent))
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/IntentReceiverActivityTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/IntentReceiverActivityTest.kt
new file mode 100644
index 0000000000..d272819446
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/IntentReceiverActivityTest.kt
@@ -0,0 +1,322 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix
+
+import android.app.Activity
+import android.content.Intent
+import android.content.Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY
+import android.net.Uri
+import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.spyk
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import mozilla.components.feature.intent.processing.IntentProcessor
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.telemetry.glean.testing.GleanTestRule
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.Events
+import org.mozilla.fenix.components.IntentProcessorType
+import org.mozilla.fenix.components.IntentProcessors
+import org.mozilla.fenix.customtabs.ExternalAppBrowserActivity
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.helpers.perf.TestStrictModeManager
+import org.mozilla.fenix.shortcut.NewTabShortcutIntentProcessor
+import org.mozilla.fenix.shortcut.PasswordManagerIntentProcessor
+import org.mozilla.fenix.utils.Settings
+import org.robolectric.Robolectric
+import org.robolectric.Shadows.shadowOf
+
+@RunWith(FenixRobolectricTestRunner::class)
+class IntentReceiverActivityTest {
+
+ private lateinit var settings: Settings
+ private lateinit var intentProcessors: IntentProcessors
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @Before
+ fun setup() {
+ mockkStatic("org.mozilla.fenix.ext.ContextKt")
+ settings = mockk()
+ intentProcessors = mockk()
+
+ every { settings.openLinksInAPrivateTab } returns false
+ every { intentProcessors.intentProcessor } returns mockIntentProcessor()
+ every { intentProcessors.privateIntentProcessor } returns mockIntentProcessor()
+ every { intentProcessors.customTabIntentProcessor } returns mockIntentProcessor()
+ every { intentProcessors.privateCustomTabIntentProcessor } returns mockIntentProcessor()
+ every { intentProcessors.externalAppIntentProcessors } returns emptyList()
+ every { intentProcessors.fennecPageShortcutIntentProcessor } returns mockIntentProcessor()
+ every { intentProcessors.externalDeepLinkIntentProcessor } returns mockIntentProcessor()
+ every { intentProcessors.webNotificationsIntentProcessor } returns mockIntentProcessor()
+ every { intentProcessors.passwordManagerIntentProcessor } returns mockIntentProcessor()
+
+ coEvery { intentProcessors.intentProcessor.process(any()) } returns true
+ }
+
+ @After
+ fun teardown() {
+ unmockkStatic("org.mozilla.fenix.ext.ContextKt")
+ }
+
+ @Test
+ fun `process intent with flag launched from history`() = runTest {
+ val intent = Intent()
+ intent.flags = FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY
+ assertNull(Events.openedLink.testGetValue())
+
+ val activity = Robolectric.buildActivity(IntentReceiverActivity::class.java, intent).get()
+ attachMocks(activity)
+ activity.processIntent(intent)
+
+ val shadow = shadowOf(activity)
+ val actualIntent = shadow.peekNextStartedActivity()
+
+ assertNotNull(Events.openedLink.testGetValue())
+ assertEquals(HomeActivity::class.java.name, actualIntent.component?.className)
+ assertEquals(true, actualIntent.flags == FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY)
+ }
+
+ @Test
+ fun `GIVEN a deeplink intent WHEN processing the intent THEN add the className HomeActivity`() =
+ runTest {
+ val uri = Uri.parse(BuildConfig.DEEP_LINK_SCHEME + "://settings_wallpapers")
+ val intent = Intent("", uri)
+ assertNull(Events.openedLink.testGetValue())
+
+ coEvery { intentProcessors.intentProcessor.process(any()) } returns false
+ coEvery { intentProcessors.externalDeepLinkIntentProcessor.process(any()) } returns true
+
+ val activity =
+ Robolectric.buildActivity(IntentReceiverActivity::class.java, intent).get()
+ attachMocks(activity)
+ activity.processIntent(intent)
+
+ val shadow = shadowOf(activity)
+ val actualIntent = shadow.peekNextStartedActivity()
+
+ assertNotNull(Events.openedLink.testGetValue())
+ assertEquals(HomeActivity::class.java.name, actualIntent.component?.className)
+ }
+
+ @Test
+ fun `process intent with action OPEN_PRIVATE_TAB`() = runTest {
+ val intent = Intent()
+ intent.action = NewTabShortcutIntentProcessor.ACTION_OPEN_PRIVATE_TAB
+ assertNull(Events.openedLink.testGetValue())
+
+ coEvery { intentProcessors.intentProcessor.process(intent) } returns false
+ coEvery { intentProcessors.customTabIntentProcessor.process(intent) } returns false
+ val activity = Robolectric.buildActivity(IntentReceiverActivity::class.java, intent).get()
+ attachMocks(activity)
+ activity.processIntent(intent)
+
+ val shadow = shadowOf(activity)
+ val actualIntent = shadow.peekNextStartedActivity()
+
+ assertNotNull(Events.openedLink.testGetValue())
+ assertEquals(HomeActivity::class.java.name, actualIntent.component?.className)
+ assertEquals(true, actualIntent.getBooleanExtra(HomeActivity.PRIVATE_BROWSING_MODE, false))
+ assertEquals(false, actualIntent.getBooleanExtra(HomeActivity.OPEN_TO_BROWSER, true))
+ }
+
+ @Test
+ fun `process intent with action OPEN_TAB`() = runTest {
+ assertNull(Events.openedLink.testGetValue())
+ val intent = Intent()
+ intent.action = NewTabShortcutIntentProcessor.ACTION_OPEN_TAB
+
+ val activity = Robolectric.buildActivity(IntentReceiverActivity::class.java, intent).get()
+ attachMocks(activity)
+ activity.processIntent(intent)
+
+ val shadow = shadowOf(activity)
+ val actualIntent = shadow.peekNextStartedActivity()
+
+ assertEquals(HomeActivity::class.java.name, actualIntent.component?.className)
+ assertEquals(false, actualIntent.getBooleanExtra(HomeActivity.PRIVATE_BROWSING_MODE, false))
+ assertNotNull(Events.openedLink.testGetValue())
+ }
+
+ @Test
+ fun `process intent starts Activity`() = runTest {
+ assertNull(Events.openedLink.testGetValue())
+ val intent = Intent()
+ val activity = Robolectric.buildActivity(IntentReceiverActivity::class.java, intent).get()
+ attachMocks(activity)
+ activity.processIntent(intent)
+
+ val shadow = shadowOf(activity)
+ val actualIntent = shadow.peekNextStartedActivity()
+
+ assertEquals(HomeActivity::class.java.name, actualIntent.component?.className)
+ assertEquals(true, actualIntent.getBooleanExtra(HomeActivity.OPEN_TO_BROWSER, true))
+ assertNotNull(Events.openedLink.testGetValue())
+ }
+
+ @Test
+ fun `process intent with launchLinksInPrivateTab set to true`() = runTest {
+ assertNull(Events.openedLink.testGetValue())
+
+ every { settings.openLinksInAPrivateTab } returns true
+
+ coEvery { intentProcessors.intentProcessor.process(any()) } returns false
+ coEvery { intentProcessors.privateIntentProcessor.process(any()) } returns true
+
+ val intent = Intent()
+ val activity = Robolectric.buildActivity(IntentReceiverActivity::class.java, intent).get()
+ attachMocks(activity)
+ activity.processIntent(intent)
+
+ val shadow = shadowOf(activity)
+ val actualIntent = shadow.peekNextStartedActivity()
+
+ val normalProcessor = intentProcessors.intentProcessor
+ verify(exactly = 0) { normalProcessor.process(intent) }
+ verify { intentProcessors.privateIntentProcessor.process(intent) }
+ assertEquals(HomeActivity::class.java.name, actualIntent.component?.className)
+ assertTrue(actualIntent.getBooleanExtra(HomeActivity.PRIVATE_BROWSING_MODE, false))
+ assertNotNull(Events.openedLink.testGetValue())
+ }
+
+ @Test
+ fun `process intent with launchLinksInPrivateTab set to false`() = runTest {
+ assertNull(Events.openedLink.testGetValue())
+ val intent = Intent()
+
+ val activity = Robolectric.buildActivity(IntentReceiverActivity::class.java, intent).get()
+ attachMocks(activity)
+ activity.processIntent(intent)
+
+ coVerify(exactly = 0) { intentProcessors.privateIntentProcessor.process(intent) }
+ coVerify { intentProcessors.intentProcessor.process(intent) }
+ assertNotNull(Events.openedLink.testGetValue())
+ }
+
+ @Test
+ fun `process intent with launchLinksInPrivateTab set to false but with external flag`() = runTest {
+ assertNull(Events.openedLink.testGetValue())
+
+ coEvery { intentProcessors.intentProcessor.process(any()) } returns false
+ coEvery { intentProcessors.privateIntentProcessor.process(any()) } returns true
+
+ val intent = Intent()
+ intent.putExtra(HomeActivity.PRIVATE_BROWSING_MODE, true)
+
+ val activity = Robolectric.buildActivity(IntentReceiverActivity::class.java, intent).get()
+ attachMocks(activity)
+ activity.processIntent(intent)
+
+ val shadow = shadowOf(activity)
+ val actualIntent = shadow.peekNextStartedActivity()
+
+ val normalProcessor = intentProcessors.intentProcessor
+ verify(exactly = 0) { normalProcessor.process(intent) }
+ verify { intentProcessors.privateIntentProcessor.process(intent) }
+ assertEquals(HomeActivity::class.java.name, actualIntent.component?.className)
+ assertTrue(actualIntent.getBooleanExtra(HomeActivity.PRIVATE_BROWSING_MODE, false))
+ assertNotNull(Events.openedLink.testGetValue())
+ }
+
+ @Test
+ fun `process custom tab intent`() = runTest {
+ assertNull(Events.openedLink.testGetValue())
+ val intent = Intent()
+ coEvery { intentProcessors.intentProcessor.process(intent) } returns false
+ coEvery { intentProcessors.customTabIntentProcessor.process(intent) } returns true
+ assertNull(Events.openedLink.testGetValue())
+
+ val activity = Robolectric.buildActivity(IntentReceiverActivity::class.java, intent).get()
+ attachMocks(activity)
+ activity.processIntent(intent)
+
+ coVerify(exactly = 0) { intentProcessors.privateCustomTabIntentProcessor.process(intent) }
+ coVerify { intentProcessors.customTabIntentProcessor.process(intent) }
+
+ assertEquals(ExternalAppBrowserActivity::class.java.name, intent.component!!.className)
+ assertTrue(intent.getBooleanExtra(HomeActivity.OPEN_TO_BROWSER, false))
+ assertNotNull(Events.openedLink.testGetValue())
+ }
+
+ @Test
+ fun `process private custom tab intent`() = runTest {
+ assertNull(Events.openedLink.testGetValue())
+ every { settings.openLinksInAPrivateTab } returns true
+
+ val intent = Intent()
+ coEvery { intentProcessors.privateCustomTabIntentProcessor.process(intent) } returns true
+
+ val activity = Robolectric.buildActivity(IntentReceiverActivity::class.java, intent).get()
+ attachMocks(activity)
+ activity.processIntent(intent)
+
+ val normalProcessor = intentProcessors.customTabIntentProcessor
+ coVerify(exactly = 0) { normalProcessor.process(intent) }
+ coVerify { intentProcessors.privateCustomTabIntentProcessor.process(intent) }
+
+ assertEquals(ExternalAppBrowserActivity::class.java.name, intent.component!!.className)
+ assertTrue(intent.getBooleanExtra(HomeActivity.OPEN_TO_BROWSER, false))
+ assertNotNull(Events.openedLink.testGetValue())
+ }
+
+ @Test
+ fun `process web notifications click intent`() {
+ val intent = Intent()
+ every { intentProcessors.webNotificationsIntentProcessor.process(intent) } returns true
+ val activity = spyk(Robolectric.buildActivity(IntentReceiverActivity::class.java, intent).get())
+ attachMocks(activity)
+ every { activity.launch(any(), any()) } just Runs
+ activity.processIntent(intent)
+
+ verify { intentProcessors.webNotificationsIntentProcessor.process(intent) }
+ verify { activity.launch(intent, IntentProcessorType.NEW_TAB) }
+ }
+
+ @Test
+ fun `process intent with action OPEN_PASSWORD_MANAGER`() = runTest {
+ val intent = Intent()
+ intent.action = PasswordManagerIntentProcessor.ACTION_OPEN_PASSWORD_MANAGER
+
+ val activity = Robolectric.buildActivity(IntentReceiverActivity::class.java, intent).get()
+ attachMocks(activity)
+ activity.processIntent(intent)
+
+ val shadow = shadowOf(activity)
+ val actualIntent = shadow.peekNextStartedActivity()
+
+ assertEquals(HomeActivity::class.java.name, actualIntent.component?.className)
+ }
+
+ private fun attachMocks(activity: Activity) {
+ every { activity.settings() } returns settings
+ every { activity.components.analytics } returns mockk(relaxed = true)
+ every { activity.components.intentProcessors } returns intentProcessors
+ every { activity.components.strictMode } returns TestStrictModeManager()
+ }
+
+ private inline fun <reified T : IntentProcessor> mockIntentProcessor(): T {
+ return mockk {
+ coEvery { process(any()) } returns false
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/MockNavHostActivity.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/MockNavHostActivity.kt
new file mode 100644
index 0000000000..54f3aa3cb9
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/MockNavHostActivity.kt
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix
+
+import androidx.appcompat.app.ActionBar
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentActivity
+import io.mockk.mockk
+import org.robolectric.Robolectric
+
+class MockNavHostActivity : AppCompatActivity(), NavHostActivity {
+
+ private val mockActionBar = mockk<ActionBar>(relaxed = true)
+
+ override fun getSupportActionBarAndInflateIfNecessary() = mockActionBar
+}
+
+/**
+ * Set up an added [Fragment] to a [FragmentActivity] that has been initialized to a resumed state.
+ *
+ * Variant of [mozilla.components.support.test.robolectric.createAddedTestFragment] that uses
+ * a custom Fenix activity to hold the fragment.
+ *
+ * @param fragmentTag the name that will be used to tag the fragment inside the [FragmentManager].
+ * @param fragmentFactory a lambda function that returns a Fragment that will be added to the Activity.
+ *
+ * @return The same [Fragment] that was returned from [fragmentFactory] after it got added to the
+ * Activity.
+ */
+inline fun <T : Fragment> createAddedTestFragmentInNavHostActivity(
+ fragmentTag: String = "test",
+ fragmentFactory: () -> T,
+): T {
+ val activity = Robolectric.buildActivity(MockNavHostActivity::class.java)
+ .create()
+ .start()
+ .resume()
+ .get()
+
+ return fragmentFactory().also {
+ activity.supportFragmentManager.beginTransaction()
+ .add(it, fragmentTag)
+ .commitNow()
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ReleaseChannelTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ReleaseChannelTest.kt
new file mode 100644
index 0000000000..b798b6e054
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ReleaseChannelTest.kt
@@ -0,0 +1,34 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.mozilla.fenix.ReleaseChannel.Debug
+
+class ReleaseChannelTest {
+
+ @Test
+ fun `isReleased and isDebug channels are mutually exclusive`() {
+ val debugChannels = setOf(
+ Debug,
+ )
+
+ val nonDebugChannels = ReleaseChannel.values().toSet() - debugChannels
+
+ nonDebugChannels.forEach {
+ val className = it.javaClass.simpleName
+ assertTrue(className, it.isReleased)
+ assertFalse(className, it.isDebug)
+ }
+
+ debugChannels.forEach {
+ val className = it.javaClass.simpleName
+ assertFalse(className, it.isReleased)
+ assertTrue(className, it.isDebug)
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ServiceWorkerSupportTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ServiceWorkerSupportTest.kt
new file mode 100644
index 0000000000..5ba5224678
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ServiceWorkerSupportTest.kt
@@ -0,0 +1,85 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix
+
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.verify
+import io.mockk.verifyOrder
+import mozilla.components.browser.engine.gecko.GeckoEngine
+import mozilla.components.browser.state.state.SessionState
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.concept.engine.EngineSession.LoadUrlFlags
+import mozilla.components.feature.tabs.TabsUseCases.AddNewTabUseCase
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.ext.components
+
+class ServiceWorkerSupportTest {
+ private lateinit var activity: HomeActivity
+ private lateinit var feature: ServiceWorkerSupportFeature
+ private lateinit var engine: GeckoEngine
+ private lateinit var addNewTabUseCase: AddNewTabUseCase
+
+ @Before
+ fun setup() {
+ activity = mockk()
+ engine = mockk(relaxed = true)
+ feature = ServiceWorkerSupportFeature(activity)
+ addNewTabUseCase = mockk(relaxed = true)
+ every { activity.components.core.engine } returns engine
+ every { activity.components.useCases.tabsUseCases.addTab } returns addNewTabUseCase
+ every { activity.openToBrowser(BrowserDirection.FromHome) } just Runs
+ }
+
+ @Test
+ fun `GIVEN the feature is registered for lifecycle events WHEN the owner is created THEN register itself as a service worker delegate`() {
+ feature.onCreate(mockk())
+
+ verify { engine.registerServiceWorkerDelegate(feature) }
+ }
+
+ @Test
+ fun `GIVEN the feature is registered for lifecycle events WHEN the owner is destroyed THEN unregister itself as a service worker delegate`() {
+ feature.onDestroy(mockk())
+
+ verify { engine.unregisterServiceWorkerDelegate() }
+ }
+
+ @Test
+ fun `WHEN a new tab is requested THEN navigate to browser then add a new tab`() {
+ val engineSession: EngineSession = mockk()
+ feature.addNewTab(engineSession)
+
+ verifyOrder {
+ activity.openToBrowser(BrowserDirection.FromHome)
+
+ addNewTabUseCase(
+ url = "about:blank",
+ selectTab = true,
+ startLoading = true,
+ parentId = null,
+ flags = LoadUrlFlags.external(),
+ contextId = null,
+ engineSession = engineSession,
+ source = SessionState.Source.Internal.None,
+ searchTerms = "",
+ private = false,
+ historyMetadata = null,
+ isSearch = false,
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN a new tab is requested THEN return true`() {
+ val result = feature.addNewTab(mockk())
+
+ assertTrue(result)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/AddonDetailsBindingDelegateTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/AddonDetailsBindingDelegateTest.kt
new file mode 100644
index 0000000000..ca27f28c9b
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/AddonDetailsBindingDelegateTest.kt
@@ -0,0 +1,233 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.addons
+
+import android.net.Uri
+import android.text.method.LinkMovementMethod
+import android.view.LayoutInflater
+import android.view.View
+import android.view.View.IMPORTANT_FOR_ACCESSIBILITY_NO
+import androidx.core.view.isVisible
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.feature.addons.Addon
+import mozilla.components.support.ktx.android.content.getColorFromAttr
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.databinding.FragmentAddOnDetailsBinding
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class AddonDetailsBindingDelegateTest {
+
+ private lateinit var view: View
+ private lateinit var binding: FragmentAddOnDetailsBinding
+ private lateinit var interactor: AddonDetailsInteractor
+ private lateinit var detailsBindingDelegate: AddonDetailsBindingDelegate
+ private val baseAddon = Addon(
+ id = "",
+ translatableDescription = mapOf(
+ Addon.DEFAULT_LOCALE to "Some blank addon\nwith a blank line",
+ ),
+ updatedAt = "2020-11-23T08:00:00Z",
+ )
+
+ @Before
+ fun setup() {
+ binding = FragmentAddOnDetailsBinding.inflate(LayoutInflater.from(testContext))
+ view = binding.root
+ interactor = mockk(relaxed = true)
+
+ detailsBindingDelegate = AddonDetailsBindingDelegate(binding, interactor)
+ }
+
+ @Test
+ fun `bind addons rating`() {
+ detailsBindingDelegate.bind(
+ baseAddon.copy(
+ rating = null,
+ ),
+ )
+ assertEquals(0f, binding.ratingView.rating)
+
+ detailsBindingDelegate.bind(
+ baseAddon.copy(
+ rating = Addon.Rating(
+ average = 4.3f,
+ reviews = 100,
+ ),
+ ),
+ )
+ assertEquals(4.5f, binding.ratingView.rating)
+ assertEquals("100", binding.reviewCount.text)
+
+ val ratingContentDescription = testContext.getString(R.string.mozac_feature_addons_rating_content_description_2)
+ var formattedRatting = String.format(ratingContentDescription, 4.3f)
+ assertEquals(formattedRatting, binding.ratingLabel.contentDescription)
+ assertEquals(IMPORTANT_FOR_ACCESSIBILITY_NO, binding.ratingView.importantForAccessibility)
+
+ val reviewContentDescription = testContext.getString(R.string.mozac_feature_addons_user_rating_count_2)
+ formattedRatting = String.format(reviewContentDescription, 100)
+ assertEquals(formattedRatting, binding.reviewCount.contentDescription)
+ }
+
+ @Test
+ fun `bind addons rating with review url`() {
+ detailsBindingDelegate.bind(
+ baseAddon.copy(
+ rating = Addon.Rating(average = 4.3f, reviews = 100),
+ ratingUrl = "https://example.org/",
+ ),
+ )
+ assertEquals("100", binding.reviewCount.text.toString())
+
+ binding.reviewCount.performClick()
+
+ verify { interactor.openWebsite(Uri.parse("https://example.org/")) }
+ }
+
+ @Test
+ fun `bind addons homepage`() {
+ detailsBindingDelegate.bind(
+ baseAddon.copy(
+ homepageUrl = "https://mozilla.org",
+ ),
+ )
+
+ binding.homePageLabel.performClick()
+
+ verify { interactor.openWebsite(Uri.parse("https://mozilla.org")) }
+ }
+
+ @Test
+ fun `bind addons last updated`() {
+ detailsBindingDelegate.bind(baseAddon)
+
+ assertEquals("Nov 23, 2020", binding.lastUpdatedText.text)
+ val expectedContentDescription = binding.lastUpdatedLabel.text.toString() + " " + "Nov 23, 2020"
+ assertEquals(expectedContentDescription, binding.lastUpdatedLabel.contentDescription)
+ assertEquals(IMPORTANT_FOR_ACCESSIBILITY_NO, binding.lastUpdatedText.importantForAccessibility)
+ }
+
+ @Test
+ fun `bind addons version`() {
+ val addon1 = baseAddon.copy(
+ version = "1.0.0",
+ installedState = null,
+ )
+
+ detailsBindingDelegate.bind(addon1)
+ assertEquals("1.0.0", binding.versionText.text)
+ binding.versionText.performLongClick()
+ verify(exactly = 0) { interactor.showUpdaterDialog(addon1) }
+
+ val addon2 = baseAddon.copy(
+ version = "1.0.0",
+ installedState = Addon.InstalledState(
+ id = "",
+ version = "2.0.0",
+ optionsPageUrl = null,
+ ),
+ )
+ detailsBindingDelegate.bind(addon2)
+ assertEquals("2.0.0", binding.versionText.text)
+ binding.versionText.performLongClick()
+ verify { interactor.showUpdaterDialog(addon2) }
+ val expectedContentDescription = binding.versionLabel.text.toString() + " 2.0.0"
+ assertEquals(expectedContentDescription, binding.versionLabel.contentDescription)
+ assertEquals(IMPORTANT_FOR_ACCESSIBILITY_NO, binding.versionText.importantForAccessibility)
+ }
+
+ @Test
+ fun `bind addons author`() {
+ detailsBindingDelegate.bind(
+ baseAddon.copy(author = Addon.Author(name = "Sarah Jane", url = "")),
+ )
+
+ assertEquals("Sarah Jane", binding.authorText.text)
+ assertNotEquals(testContext.getColorFromAttr(R.attr.textAccent), binding.authorText.currentTextColor)
+ val expectedContentDescription = binding.authorLabel.text.toString() + " Sarah Jane"
+ assertEquals(expectedContentDescription, binding.authorLabel.contentDescription)
+ assertEquals(IMPORTANT_FOR_ACCESSIBILITY_NO, binding.authorText.importantForAccessibility)
+ }
+
+ @Test
+ fun `bind addons author with url`() {
+ detailsBindingDelegate.bind(
+ baseAddon.copy(author = Addon.Author(name = "Sarah Jane", url = "https://example.org/")),
+ )
+
+ assertEquals("Sarah Jane", binding.authorText.text.toString())
+ assertEquals(testContext.getColorFromAttr(R.attr.textAccent), binding.authorText.currentTextColor)
+
+ binding.authorText.performClick()
+
+ verify { interactor.openWebsite(Uri.parse("https://example.org/")) }
+ }
+
+ @Test
+ fun `bind addons details`() {
+ detailsBindingDelegate.bind(baseAddon)
+
+ assertEquals(
+ "Some blank addon\nwith a blank line",
+ binding.details.text.toString(),
+ )
+ assertTrue(binding.details.movementMethod is LinkMovementMethod)
+ }
+
+ @Test
+ fun `bind without last updated date`() {
+ detailsBindingDelegate.bind(baseAddon.copy(updatedAt = ""))
+
+ assertFalse(binding.lastUpdatedLabel.isVisible)
+ assertFalse(binding.lastUpdatedText.isVisible)
+ assertFalse(binding.lastUpdatedDivider.isVisible)
+ }
+
+ @Test
+ fun `bind without author`() {
+ detailsBindingDelegate.bind(baseAddon.copy(author = null))
+
+ assertFalse(binding.authorLabel.isVisible)
+ assertFalse(binding.authorText.isVisible)
+ assertFalse(binding.authorDivider.isVisible)
+ }
+
+ @Test
+ fun `bind without a home page`() {
+ detailsBindingDelegate.bind(baseAddon.copy(homepageUrl = ""))
+
+ assertFalse(binding.homePageLabel.isVisible)
+ assertFalse(binding.homePageDivider.isVisible)
+ }
+
+ @Test
+ fun `bind add-on detail url`() {
+ detailsBindingDelegate.bind(baseAddon.copy(detailUrl = "https://example.org"))
+
+ assertTrue(binding.detailUrl.isVisible)
+ assertTrue(binding.detailUrlDivider.isVisible)
+
+ binding.detailUrl.performClick()
+
+ verify { interactor.openWebsite(Uri.parse("https://example.org")) }
+ }
+
+ @Test
+ fun `bind add-on without a detail url`() {
+ detailsBindingDelegate.bind(baseAddon.copy(detailUrl = ""))
+
+ assertFalse(binding.detailUrl.isVisible)
+ assertFalse(binding.detailUrlDivider.isVisible)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/AddonPermissionDetailsBindingDelegateTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/AddonPermissionDetailsBindingDelegateTest.kt
new file mode 100644
index 0000000000..52df86bac0
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/AddonPermissionDetailsBindingDelegateTest.kt
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.addons
+
+import android.view.LayoutInflater
+import android.view.View
+import androidx.core.net.toUri
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.feature.addons.Addon
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.databinding.FragmentAddOnPermissionsBinding
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class AddonPermissionDetailsBindingDelegateTest {
+
+ private lateinit var view: View
+ private lateinit var binding: FragmentAddOnPermissionsBinding
+ private lateinit var interactor: AddonPermissionsDetailsInteractor
+ private lateinit var permissionDetailsBindingDelegate: AddonPermissionDetailsBindingDelegate
+ private val addon = Addon(
+ id = "",
+ translatableName = mapOf(
+ Addon.DEFAULT_LOCALE to "Some blank addon",
+ ),
+ )
+ private val learnMoreUrl =
+ "https://support.mozilla.org/kb/permission-request-messages-firefox-extensions"
+
+ @Before
+ fun setup() {
+ binding = FragmentAddOnPermissionsBinding.inflate(LayoutInflater.from(testContext))
+ view = binding.root
+ interactor = mockk(relaxed = true)
+ permissionDetailsBindingDelegate = AddonPermissionDetailsBindingDelegate(binding, interactor)
+ }
+
+ @Test
+ fun `clicking learn more opens learn more page in browser`() {
+ permissionDetailsBindingDelegate.bind(
+ addon.copy(
+ rating = null,
+ ),
+ )
+
+ permissionDetailsBindingDelegate.binding.learnMoreLabel.performClick()
+
+ verify { interactor.openWebsite(learnMoreUrl.toUri()) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/AddonsManagementViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/AddonsManagementViewTest.kt
new file mode 100644
index 0000000000..3454d5b49c
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/AddonsManagementViewTest.kt
@@ -0,0 +1,158 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.addons
+
+import androidx.navigation.NavController
+import androidx.navigation.NavDestination
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.RelaxedMockK
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.feature.addons.Addon
+import mozilla.components.feature.addons.ui.AddonsManagerAdapterDelegate
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.addons.AddonsManagementFragmentDirections.Companion.actionAddonsManagementFragmentToAddonDetailsFragment
+import org.mozilla.fenix.addons.AddonsManagementFragmentDirections.Companion.actionAddonsManagementFragmentToInstalledAddonDetails
+import org.mozilla.fenix.ext.directionsEq
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class AddonsManagementViewTest {
+
+ @RelaxedMockK private lateinit var navController: NavController
+ private lateinit var managementView: AddonsManagerAdapterDelegate
+ private var showPermissionDialog: (Addon) -> Unit = { permissionDialogDisplayed = true }
+ private var permissionDialogDisplayed = false
+
+ private var onMoreAddonsButtonClicked: () -> Unit = { moreAddonsButtonClicked = true }
+ private var moreAddonsButtonClicked = false
+
+ private var onLearnMoreLinkClicked: (AddonsManagerAdapterDelegate.LearnMoreLinks, Addon) -> Unit = {
+ _: AddonsManagerAdapterDelegate.LearnMoreLinks, _: Addon ->
+ learnMoreLinkClicked = true
+ }
+ private var learnMoreLinkClicked = false
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ managementView = AddonsManagementView(
+ navController,
+ showPermissionDialog,
+ onMoreAddonsButtonClicked,
+ onLearnMoreLinkClicked,
+ )
+ }
+
+ @Test
+ fun `onAddonItemClicked shows installed details if addon is installed`() {
+ val addon = mockk<Addon> {
+ every { isInstalled() } returns true
+ }
+
+ every { navController.currentDestination } returns NavDestination("").apply {
+ id = R.id.addonsManagementFragment
+ }
+
+ managementView.onAddonItemClicked(addon)
+
+ val expected = actionAddonsManagementFragmentToInstalledAddonDetails(addon)
+ verify {
+ navController.navigate(directionsEq(expected))
+ }
+ }
+
+ @Test
+ fun `onAddonItemClicked shows details if addon is not installed`() {
+ val addon = mockk<Addon> {
+ every { isInstalled() } returns false
+ }
+
+ every { navController.currentDestination } returns NavDestination("").apply {
+ id = R.id.addonsManagementFragment
+ }
+
+ managementView.onAddonItemClicked(addon)
+
+ val expected = actionAddonsManagementFragmentToAddonDetailsFragment(addon)
+ verify {
+ navController.navigate(directionsEq(expected))
+ }
+ }
+
+ @Test
+ fun `onAddonItemClicked on not installed addon does not navigate if not currently on addonsManagementFragment`() {
+ val addon = mockk<Addon> {
+ every { isInstalled() } returns false
+ }
+
+ every { navController.currentDestination } returns NavDestination("").apply {
+ id = R.id.settingsFragment
+ }
+
+ managementView.onAddonItemClicked(addon)
+
+ val expected = actionAddonsManagementFragmentToAddonDetailsFragment(addon)
+ verify(exactly = 0) {
+ navController.navigate(directionsEq(expected))
+ }
+ }
+
+ @Test
+ fun `onAddonItemClicked on installed addon does not navigate if not currently on addonsManagementFragment`() {
+ val addon = mockk<Addon> {
+ every { isInstalled() } returns true
+ }
+
+ every { navController.currentDestination } returns NavDestination("").apply {
+ id = R.id.settingsFragment
+ }
+
+ managementView.onAddonItemClicked(addon)
+
+ val expected = actionAddonsManagementFragmentToAddonDetailsFragment(addon)
+ verify(exactly = 0) {
+ navController.navigate(directionsEq(expected))
+ }
+ }
+
+ @Test
+ fun `onInstallAddonButtonClicked shows permission dialog`() {
+ val addon = mockk<Addon>()
+ managementView.onInstallAddonButtonClicked(addon)
+ assertTrue(permissionDialogDisplayed)
+ }
+
+ @Test
+ fun `onNotYetSupportedSectionClicked shows not yet supported fragment`() {
+ val addons = listOf<Addon>(mockk(), mockk())
+ managementView.onNotYetSupportedSectionClicked(addons)
+
+ val expected = AddonsManagementFragmentDirections.actionAddonsManagementFragmentToNotYetSupportedAddonFragment(
+ addons.toTypedArray(),
+ )
+ verify {
+ navController.navigate(directionsEq(expected))
+ }
+ }
+
+ @Test
+ fun `onFindMoreAddonsButtonClicked calls onMoreAddonsButtonClicked`() {
+ managementView.onFindMoreAddonsButtonClicked()
+ assertTrue(moreAddonsButtonClicked)
+ }
+
+ @Test
+ fun `onLearnMoreLinkClicked calls onLearnMore`() {
+ val addon: Addon = mockk()
+ managementView.onLearnMoreLinkClicked(AddonsManagerAdapterDelegate.LearnMoreLinks.BLOCKLISTED_ADDON, addon)
+ assertTrue(learnMoreLinkClicked)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/ExtensionsProcessDisabledBackgroundControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/ExtensionsProcessDisabledBackgroundControllerTest.kt
new file mode 100644
index 0000000000..e641963944
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/ExtensionsProcessDisabledBackgroundControllerTest.kt
@@ -0,0 +1,70 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.addons
+
+import mozilla.components.browser.state.action.ExtensionsProcessAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppState
+
+class ExtensionsProcessDisabledBackgroundControllerTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val dispatcher = coroutinesTestRule.testDispatcher
+
+ @Test
+ fun `WHEN app is backgrounded AND extension process spawning threshold is exceeded THEN onExtensionsProcessDisabled is invoked`() {
+ val browserStore = BrowserStore(BrowserState())
+ val appStore = AppStore(AppState(isForeground = false))
+ var invoked = false
+
+ val controller = ExtensionsProcessDisabledBackgroundController(
+ browserStore,
+ appStore,
+ onExtensionsProcessDisabled = {
+ invoked = true
+ },
+ )
+
+ controller.start()
+
+ browserStore.dispatch(ExtensionsProcessAction.ShowPromptAction(show = true))
+ dispatcher.scheduler.advanceUntilIdle()
+ browserStore.waitUntilIdle()
+
+ assertTrue(invoked)
+ }
+
+ @Test
+ fun `WHEN app is in foreground AND extension process spawning threshold is exceeded THEN onExtensionsProcessDisabled is not invoked`() {
+ val browserStore = BrowserStore(BrowserState())
+ val appStore = AppStore(AppState(isForeground = true))
+ var invoked = false
+
+ val controller = ExtensionsProcessDisabledBackgroundController(
+ browserStore,
+ appStore,
+ onExtensionsProcessDisabled = {
+ invoked = true
+ },
+ )
+
+ controller.start()
+
+ browserStore.dispatch(ExtensionsProcessAction.ShowPromptAction(show = true))
+ dispatcher.scheduler.advanceUntilIdle()
+ browserStore.waitUntilIdle()
+
+ assertFalse(invoked)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/ExtensionsProcessDisabledForegroundControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/ExtensionsProcessDisabledForegroundControllerTest.kt
new file mode 100644
index 0000000000..7170ec97f8
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/ExtensionsProcessDisabledForegroundControllerTest.kt
@@ -0,0 +1,154 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.addons
+
+import android.view.View
+import android.widget.Button
+import androidx.appcompat.app.AlertDialog
+import mozilla.components.browser.state.action.ExtensionsProcessAction
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.support.test.argumentCaptor
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.whenever
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import org.mozilla.fenix.R
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppState
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class ExtensionsProcessDisabledForegroundControllerTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val dispatcher = coroutinesTestRule.testDispatcher
+
+ @Test
+ fun `WHEN showExtensionsProcessDisabledPrompt is true AND positive button clicked then enable extension process spawning`() {
+ val browserStore = BrowserStore()
+ val dialog: AlertDialog = mock()
+ val builder: AlertDialog.Builder = mock()
+ val controller = ExtensionsProcessDisabledForegroundController(
+ context = testContext,
+ appStore = AppStore(AppState(isForeground = true)),
+ browserStore = browserStore,
+ builder = builder,
+ appName = "TestApp",
+ )
+ val buttonsContainerCaptor = argumentCaptor<View>()
+
+ controller.start()
+
+ whenever(builder.show()).thenReturn(dialog)
+
+ assertFalse(browserStore.state.showExtensionsProcessDisabledPrompt)
+ assertFalse(browserStore.state.extensionsProcessDisabled)
+
+ // Pretend the process has been disabled and we show the dialog.
+ browserStore.dispatch(ExtensionsProcessAction.DisabledAction)
+ browserStore.dispatch(ExtensionsProcessAction.ShowPromptAction(show = true))
+ dispatcher.scheduler.advanceUntilIdle()
+ browserStore.waitUntilIdle()
+ assertTrue(browserStore.state.showExtensionsProcessDisabledPrompt)
+ assertTrue(browserStore.state.extensionsProcessDisabled)
+
+ verify(builder).setView(buttonsContainerCaptor.capture())
+ verify(builder).show()
+
+ buttonsContainerCaptor.value.findViewById<Button>(R.id.positive).performClick()
+
+ browserStore.waitUntilIdle()
+
+ assertFalse(browserStore.state.showExtensionsProcessDisabledPrompt)
+ assertFalse(browserStore.state.extensionsProcessDisabled)
+ verify(dialog).dismiss()
+ }
+
+ @Test
+ fun `WHEN showExtensionsProcessDisabledPrompt is true AND negative button clicked then dismiss without enabling extension process spawning`() {
+ val browserStore = BrowserStore()
+ val dialog: AlertDialog = mock()
+ val builder: AlertDialog.Builder = mock()
+ val controller = ExtensionsProcessDisabledForegroundController(
+ context = testContext,
+ appStore = AppStore(AppState(isForeground = true)),
+ browserStore = browserStore,
+ builder = builder,
+ appName = "TestApp",
+ )
+ val buttonsContainerCaptor = argumentCaptor<View>()
+
+ controller.start()
+
+ whenever(builder.show()).thenReturn(dialog)
+
+ assertFalse(browserStore.state.showExtensionsProcessDisabledPrompt)
+ assertFalse(browserStore.state.extensionsProcessDisabled)
+
+ // Pretend the process has been disabled and we show the dialog.
+ browserStore.dispatch(ExtensionsProcessAction.DisabledAction)
+ browserStore.dispatch(ExtensionsProcessAction.ShowPromptAction(show = true))
+ dispatcher.scheduler.advanceUntilIdle()
+ browserStore.waitUntilIdle()
+ assertTrue(browserStore.state.showExtensionsProcessDisabledPrompt)
+ assertTrue(browserStore.state.extensionsProcessDisabled)
+
+ verify(builder).setView(buttonsContainerCaptor.capture())
+ verify(builder).show()
+
+ buttonsContainerCaptor.value.findViewById<Button>(R.id.negative).performClick()
+
+ browserStore.waitUntilIdle()
+
+ assertFalse(browserStore.state.showExtensionsProcessDisabledPrompt)
+ assertTrue(browserStore.state.extensionsProcessDisabled)
+ verify(dialog).dismiss()
+ }
+
+ @Test
+ fun `WHEN dispatching the same event twice THEN the dialog should only be created once`() {
+ val browserStore = BrowserStore()
+ val dialog: AlertDialog = mock()
+ val builder: AlertDialog.Builder = mock()
+ val controller = ExtensionsProcessDisabledForegroundController(
+ context = testContext,
+ appStore = AppStore(AppState(isForeground = true)),
+ browserStore = browserStore,
+ builder = builder,
+ appName = "TestApp",
+ )
+ val buttonsContainerCaptor = argumentCaptor<View>()
+
+ controller.start()
+
+ whenever(builder.show()).thenReturn(dialog)
+
+ // First dispatch...
+ browserStore.dispatch(ExtensionsProcessAction.ShowPromptAction(show = true))
+ dispatcher.scheduler.advanceUntilIdle()
+ browserStore.waitUntilIdle()
+
+ // Second dispatch... without having dismissed the dialog before!
+ browserStore.dispatch(ExtensionsProcessAction.ShowPromptAction(show = true))
+ dispatcher.scheduler.advanceUntilIdle()
+ browserStore.waitUntilIdle()
+
+ verify(builder).setView(buttonsContainerCaptor.capture())
+ verify(builder, times(1)).show()
+
+ // Click a button to dismiss the dialog.
+ buttonsContainerCaptor.value.findViewById<Button>(R.id.negative).performClick()
+ browserStore.waitUntilIdle()
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/InstalledAddonDetailsFragmentTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/InstalledAddonDetailsFragmentTest.kt
new file mode 100644
index 0000000000..2f88c08178
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/InstalledAddonDetailsFragmentTest.kt
@@ -0,0 +1,260 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.addons
+
+import android.view.LayoutInflater
+import androidx.navigation.NavController
+import androidx.navigation.Navigation
+import com.google.android.material.switchmaterial.SwitchMaterial
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.concept.engine.webextension.EnableSource
+import mozilla.components.feature.addons.Addon
+import mozilla.components.feature.addons.AddonManager
+import mozilla.components.feature.tabs.TabsUseCases
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.databinding.FragmentInstalledAddOnDetailsBinding
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class InstalledAddonDetailsFragmentTest {
+
+ private lateinit var fragment: InstalledAddonDetailsFragment
+ private val addonManager = mockk<AddonManager>()
+
+ @Before
+ fun setup() {
+ fragment = spyk(InstalledAddonDetailsFragment())
+ }
+
+ @Test
+ fun `GIVEN add-on is supported and not disabled WHEN enabling it THEN the add-on is requested by the user`() {
+ val addon = mockk<Addon>()
+ every { addon.isDisabledAsUnsupported() } returns false
+ every { addon.isSupported() } returns true
+ every { fragment.addon } returns addon
+ every { addonManager.enableAddon(any(), any(), any(), any()) } just Runs
+
+ fragment.enableAddon(addonManager, {}, {})
+
+ verify { addonManager.enableAddon(addon, EnableSource.USER, any(), any()) }
+ }
+
+ @Test
+ fun `GIVEN add-on is supported and disabled as previously unsupported WHEN enabling it THEN the add-on is requested by both the app and the user`() {
+ val addon = mockk<Addon>()
+ every { addon.isDisabledAsUnsupported() } returns true
+ every { addon.isSupported() } returns true
+ every { fragment.addon } returns addon
+ val capturedAddon = slot<Addon>()
+ val capturedOnSuccess = slot<(Addon) -> Unit>()
+ every {
+ addonManager.enableAddon(
+ capture(capturedAddon),
+ any(),
+ capture(capturedOnSuccess),
+ any(),
+ )
+ } answers { capturedOnSuccess.captured.invoke(capturedAddon.captured) }
+
+ fragment.enableAddon(addonManager, {}, {})
+
+ verify { addonManager.enableAddon(addon, EnableSource.APP_SUPPORT, any(), any()) }
+ verify { addonManager.enableAddon(capturedAddon.captured, EnableSource.USER, any(), any()) }
+ }
+
+ @Test
+ fun `GIVEN blocklisted addon WHEN binding the enable switch THEN disable the switch`() {
+ val addon = mockk<Addon>()
+ val enableSwitch = mockk<SwitchMaterial>(relaxed = true)
+ val privateBrowsingSwitch = mockk<SwitchMaterial>(relaxed = true)
+
+ every { fragment.provideEnableSwitch() } returns enableSwitch
+ every { fragment.providePrivateBrowsingSwitch() } returns privateBrowsingSwitch
+ every { addon.isEnabled() } returns true
+ every { addon.isDisabledAsBlocklisted() } returns true
+ every { fragment.addon } returns addon
+
+ fragment.bindEnableSwitch()
+
+ verify { enableSwitch.isEnabled = false }
+ }
+
+ @Test
+ fun `GIVEN enabled addon WHEN binding the enable switch THEN do not disable the switch`() {
+ val addon = mockk<Addon>()
+ val enableSwitch = mockk<SwitchMaterial>(relaxed = true)
+ val privateBrowsingSwitch = mockk<SwitchMaterial>(relaxed = true)
+
+ every { fragment.provideEnableSwitch() } returns enableSwitch
+ every { fragment.providePrivateBrowsingSwitch() } returns privateBrowsingSwitch
+ every { addon.isDisabledAsBlocklisted() } returns false
+ every { addon.isDisabledAsNotCorrectlySigned() } returns false
+ every { addon.isDisabledAsIncompatible() } returns false
+ every { addon.isEnabled() } returns true
+ every { fragment.addon } returns addon
+
+ fragment.bindEnableSwitch()
+
+ verify(exactly = 0) { enableSwitch.isEnabled = false }
+ }
+
+ @Test
+ fun `GIVEN addon not correctly signed WHEN binding the enable switch THEN disable the switch`() {
+ val addon = mockk<Addon>()
+ val enableSwitch = mockk<SwitchMaterial>(relaxed = true)
+ val privateBrowsingSwitch = mockk<SwitchMaterial>(relaxed = true)
+
+ every { fragment.provideEnableSwitch() } returns enableSwitch
+ every { fragment.providePrivateBrowsingSwitch() } returns privateBrowsingSwitch
+ every { addon.isEnabled() } returns true
+ every { addon.isDisabledAsBlocklisted() } returns false
+ every { addon.isDisabledAsNotCorrectlySigned() } returns true
+ every { fragment.addon } returns addon
+
+ fragment.bindEnableSwitch()
+
+ verify { enableSwitch.isEnabled = false }
+ }
+
+ @Test
+ fun `GIVEN incompatible addon WHEN binding the enable switch THEN disable the switch`() {
+ val addon = mockk<Addon>()
+ val enableSwitch = mockk<SwitchMaterial>(relaxed = true)
+ val privateBrowsingSwitch = mockk<SwitchMaterial>(relaxed = true)
+
+ every { fragment.provideEnableSwitch() } returns enableSwitch
+ every { fragment.providePrivateBrowsingSwitch() } returns privateBrowsingSwitch
+ every { addon.isEnabled() } returns true
+ every { addon.isDisabledAsBlocklisted() } returns false
+ every { addon.isDisabledAsNotCorrectlySigned() } returns false
+ every { addon.isDisabledAsIncompatible() } returns true
+ every { fragment.addon } returns addon
+
+ fragment.bindEnableSwitch()
+
+ verify { enableSwitch.isEnabled = false }
+ }
+
+ @Test
+ fun `GIVEN an add-on WHEN clicking the report button THEN a new tab is open`() {
+ val addon = mockAddon()
+ every { fragment.addon } returns addon
+ every { fragment.activity } returns mockk<HomeActivity>(relaxed = true)
+ val useCases = mockk<TabsUseCases>()
+ val selectOrAddTab = mockk<TabsUseCases.SelectOrAddUseCase>()
+ every { selectOrAddTab.invoke(any(), any(), any(), any(), any()) } returns "some-tab-id"
+ every { useCases.selectOrAddTab } returns selectOrAddTab
+ every { testContext.components.useCases.tabsUseCases } returns useCases
+ // We create the `binding` instance and bind the UI here because `onCreateView()` checks a late init variable
+ // and we cannot easily mock it to skip the check.
+ fragment.setBindingAndBindUI(
+ FragmentInstalledAddOnDetailsBinding.inflate(
+ LayoutInflater.from(testContext),
+ mockk(relaxed = true),
+ false,
+ ),
+ )
+ val navController = mockk<NavController>(relaxed = true)
+ Navigation.setViewNavController(fragment.binding.root, navController)
+
+ // Click the report button.
+ fragment.binding.reportAddOn.performClick()
+
+ verify {
+ selectOrAddTab.invoke(
+ url = "https://addons.mozilla.org/android/feedback/addon/some-addon-id/",
+ private = false,
+ ignoreFragment = true,
+ )
+ }
+ verify {
+ navController.navigate(
+ InstalledAddonDetailsFragmentDirections.actionGlobalBrowser(null),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN an add-on and private browsing mode is used WHEN clicking the report button THEN a new private tab is open`() {
+ val addon = mockAddon()
+ every { fragment.addon } returns addon
+ val homeActivity = mockk<HomeActivity>(relaxed = true)
+ every { homeActivity.browsingModeManager.mode.isPrivate } returns true
+ every { fragment.activity } returns homeActivity
+ val useCases = mockk<TabsUseCases>()
+ val selectOrAddTab = mockk<TabsUseCases.SelectOrAddUseCase>()
+ every { selectOrAddTab.invoke(any(), any(), any(), any(), any()) } returns "some-tab-id"
+ every { useCases.selectOrAddTab } returns selectOrAddTab
+ every { testContext.components.useCases.tabsUseCases } returns useCases
+ // We create the `binding` instance and bind the UI here because `onCreateView()` checks a late init variable
+ // and we cannot easily mock it to skip the check.
+ fragment.setBindingAndBindUI(
+ FragmentInstalledAddOnDetailsBinding.inflate(
+ LayoutInflater.from(testContext),
+ mockk(relaxed = true),
+ false,
+ ),
+ )
+ val navController = mockk<NavController>(relaxed = true)
+ Navigation.setViewNavController(fragment.binding.root, navController)
+
+ // Click the report button.
+ fragment.binding.reportAddOn.performClick()
+
+ verify {
+ selectOrAddTab.invoke(
+ url = "https://addons.mozilla.org/android/feedback/addon/some-addon-id/",
+ private = true,
+ ignoreFragment = true,
+ )
+ }
+ verify {
+ navController.navigate(
+ InstalledAddonDetailsFragmentDirections.actionGlobalBrowser(null),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN addon does not allow private browsing WHEN binding THEN update switch`() {
+ val addon = mockAddon()
+ val privateBrowsingSwitch = mockk<SwitchMaterial>(relaxed = true)
+
+ every { fragment.providePrivateBrowsingSwitch() } returns privateBrowsingSwitch
+ every { addon.incognito } returns Addon.Incognito.NOT_ALLOWED
+ every { fragment.addon } returns addon
+ every { fragment.context } returns testContext
+
+ fragment.bindAllowInPrivateBrowsingSwitch()
+
+ verify { privateBrowsingSwitch.isEnabled = false }
+ verify { privateBrowsingSwitch.isChecked = false }
+ verify { privateBrowsingSwitch.text = "Not allowed in private windows" }
+ }
+
+ private fun mockAddon(): Addon {
+ val addon: Addon = mockk()
+ every { addon.id } returns "some-addon-id"
+ every { addon.incognito } returns Addon.Incognito.SPANNING
+ every { addon.isEnabled() } returns true
+ every { addon.isDisabledAsBlocklisted() } returns false
+ every { addon.isDisabledAsNotCorrectlySigned() } returns false
+ every { addon.isDisabledAsIncompatible() } returns false
+ every { addon.installedState } returns null
+ every { addon.isAllowedInPrivateBrowsing() } returns false
+ return addon
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/bindings/BrowserStoreBindingTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/bindings/BrowserStoreBindingTest.kt
new file mode 100644
index 0000000000..0aaf57ca0f
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/bindings/BrowserStoreBindingTest.kt
@@ -0,0 +1,79 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.bindings
+
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppAction
+import org.mozilla.fenix.components.appstate.AppState
+
+class BrowserStoreBindingTest {
+
+ @get:Rule
+ val coroutineRule = MainCoroutineRule()
+
+ lateinit var browserStore: BrowserStore
+ lateinit var appStore: AppStore
+
+ private val tabId1 = "1"
+ private val tabId2 = "2"
+ private val tab1 = createTab(url = tabId1, id = tabId1)
+ private val tab2 = createTab(url = tabId2, id = tabId2)
+
+ @Test
+ fun `WHEN selected tab changes THEN app action dispatched with update`() = runTestOnMain {
+ appStore = spy(AppStore())
+ browserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(tab1, tab2),
+ selectedTabId = tabId1,
+ ),
+ )
+
+ val binding = BrowserStoreBinding(browserStore, appStore)
+ binding.start()
+ browserStore.dispatch(TabListAction.SelectTabAction(tabId2)).joinBlocking()
+
+ // consume initial state
+ verify(appStore).dispatch(AppAction.SelectedTabChanged(tab1))
+ // verify response to Browser Store dispatch
+ verify(appStore).dispatch(AppAction.SelectedTabChanged(tab2))
+ }
+
+ @Test
+ fun `GIVEN selected tab id is set WHEN update is observed with same id THEN update is ignored`() {
+ appStore = spy(
+ AppStore(
+ AppState(
+ selectedTabId = tabId2,
+ ),
+ ),
+ )
+ browserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(tab1, tab2),
+ selectedTabId = tabId2,
+ ),
+ )
+
+ val binding = BrowserStoreBinding(browserStore, appStore)
+ binding.start()
+ browserStore.dispatch(TabListAction.SelectTabAction(tabId2)).joinBlocking()
+
+ // the selected tab should only be dispatched on initialization
+ verify(appStore, never()).dispatch(AppAction.SelectedTabChanged(tab2))
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/BaseBrowserFragmentTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/BaseBrowserFragmentTest.kt
new file mode 100644
index 0000000000..56708c8284
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/BaseBrowserFragmentTest.kt
@@ -0,0 +1,265 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.browser
+
+import android.content.Context
+import android.view.View
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.spyk
+import io.mockk.verify
+import junit.framework.TestCase.assertFalse
+import junit.framework.TestCase.assertTrue
+import mozilla.components.browser.state.state.SessionState
+import mozilla.components.browser.state.state.content.DownloadState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.concept.engine.EngineView
+import mozilla.components.concept.engine.permission.SitePermissions
+import mozilla.components.feature.contextmenu.ContextMenuCandidate
+import mozilla.components.ui.widgets.VerticalSwipeRefreshLayout
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.components.toolbar.navbar.EngineViewClippingBehavior
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.utils.Settings
+
+class BaseBrowserFragmentTest {
+ private lateinit var fragment: TestBaseBrowserFragment
+ private lateinit var swipeRefreshLayout: VerticalSwipeRefreshLayout
+ private lateinit var engineView: EngineView
+ private lateinit var settings: Settings
+ private lateinit var testContext: Context
+
+ @Before
+ fun setup() {
+ fragment = spyk(TestBaseBrowserFragment())
+ swipeRefreshLayout = mockk(relaxed = true)
+ engineView = mockk(relaxed = true)
+ settings = mockk(relaxed = true)
+ testContext = mockk(relaxed = true)
+
+ every { testContext.components.settings } returns settings
+ every { fragment.isAdded } returns true
+ every { fragment.activity } returns mockk()
+ every { fragment.requireContext() } returns testContext
+ every { fragment.getEngineView() } returns engineView
+ every { fragment.getSwipeRefreshLayout() } returns swipeRefreshLayout
+ every { swipeRefreshLayout.layoutParams } returns mockk<CoordinatorLayout.LayoutParams>(relaxed = true)
+ }
+
+ @Test
+ fun `initializeEngineView should setDynamicToolbarMaxHeight to 0 if top toolbar is forced for a11y`() {
+ every { settings.shouldUseBottomToolbar } returns false
+ every { settings.shouldUseFixedTopToolbar } returns true
+
+ fragment.initializeEngineView(
+ topToolbarHeight = 13,
+ bottomToolbarHeight = 0,
+ )
+
+ verify { engineView.setDynamicToolbarMaxHeight(0) }
+ }
+
+ @Test
+ fun `initializeEngineView should setDynamicToolbarMaxHeight to 0 if bottom toolbar is forced for a11y`() {
+ every { settings.shouldUseBottomToolbar } returns true
+ every { settings.shouldUseFixedTopToolbar } returns true
+
+ fragment.initializeEngineView(
+ topToolbarHeight = 13,
+ bottomToolbarHeight = 13,
+ )
+
+ verify { engineView.setDynamicToolbarMaxHeight(0) }
+ }
+
+ @Test
+ fun `initializeEngineView should setDynamicToolbarMaxHeight to toolbar height if dynamic toolbar is enabled`() {
+ every { settings.shouldUseFixedTopToolbar } returns false
+ every { settings.isDynamicToolbarEnabled } returns true
+
+ fragment.initializeEngineView(
+ topToolbarHeight = 13,
+ bottomToolbarHeight = 0,
+ )
+
+ verify { engineView.setDynamicToolbarMaxHeight(13) }
+ }
+
+ @Test
+ fun `initializeEngineView should setDynamicToolbarMaxHeight to 0 if dynamic toolbar is disabled`() {
+ every { settings.shouldUseFixedTopToolbar } returns false
+ every { settings.isDynamicToolbarEnabled } returns false
+
+ fragment.initializeEngineView(
+ topToolbarHeight = 13,
+ bottomToolbarHeight = 0,
+ )
+
+ verify { engineView.setDynamicToolbarMaxHeight(0) }
+ }
+
+ @Test
+ fun `initializeEngineView should set EngineViewClippingBehavior when dynamic toolbar is enabled`() {
+ every { settings.shouldUseFixedTopToolbar } returns false
+ every { settings.isDynamicToolbarEnabled } returns true
+ every { settings.enableIncompleteToolbarRedesign } returns true
+ val params: CoordinatorLayout.LayoutParams = mockk(relaxed = true)
+ every { params.behavior } returns mockk(relaxed = true)
+ every { swipeRefreshLayout.layoutParams } returns params
+ val behavior = slot<EngineViewClippingBehavior>()
+
+ fragment.initializeEngineView(
+ topToolbarHeight = 13,
+ bottomToolbarHeight = 0,
+ )
+
+ // EngineViewClippingBehavior constructor parameters are not properties, we cannot check them.
+ // Ensure just that the right behavior is set.
+ verify { params.behavior = capture(behavior) }
+ }
+
+ @Test
+ fun `initializeEngineView should set toolbar height as EngineView parent's bottom margin when using bottom toolbar`() {
+ every { settings.isDynamicToolbarEnabled } returns false
+ every { settings.shouldUseBottomToolbar } returns true
+
+ fragment.initializeEngineView(
+ topToolbarHeight = 0,
+ bottomToolbarHeight = 13,
+ )
+
+ verify { (swipeRefreshLayout.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin = 13 }
+ }
+
+ @Test
+ fun `initializeEngineView should set toolbar height as EngineView parent's bottom margin if top toolbar is forced for a11y`() {
+ every { settings.shouldUseBottomToolbar } returns false
+ every { settings.shouldUseFixedTopToolbar } returns true
+
+ fragment.initializeEngineView(
+ topToolbarHeight = 13,
+ bottomToolbarHeight = 0,
+ )
+
+ verify { (swipeRefreshLayout.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin = 13 }
+ }
+
+ @Test
+ fun `initializeEngineView should set toolbar height as EngineView parent's bottom margin if bottom toolbar is forced for a11y`() {
+ every { settings.shouldUseBottomToolbar } returns true
+ every { settings.shouldUseFixedTopToolbar } returns true
+
+ fragment.initializeEngineView(
+ topToolbarHeight = 0,
+ bottomToolbarHeight = 13,
+ )
+
+ verify { (swipeRefreshLayout.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin = 13 }
+ }
+
+ @Test
+ fun `WHEN status is equals to FAILED or COMPLETED and it is the same tab then shouldShowCompletedDownloadDialog will be true`() {
+ every { fragment.getCurrentTab() } returns createTab(id = "1", url = "")
+
+ val download = DownloadState(
+ url = "",
+ sessionId = "1",
+ destinationDirectory = "/",
+ )
+
+ val status = DownloadState.Status.values()
+ .filter { it == DownloadState.Status.COMPLETED && it == DownloadState.Status.FAILED }
+
+ status.forEach {
+ val result =
+ fragment.shouldShowCompletedDownloadDialog(download, it)
+
+ assertTrue(result)
+ }
+ }
+
+ @Test
+ fun `WHEN status is different from FAILED or COMPLETED then shouldShowCompletedDownloadDialog will be false`() {
+ every { fragment.getCurrentTab() } returns createTab(id = "1", url = "")
+
+ val download = DownloadState(
+ url = "",
+ sessionId = "1",
+ destinationDirectory = "/",
+ )
+
+ val status = DownloadState.Status.values()
+ .filter { it != DownloadState.Status.COMPLETED && it != DownloadState.Status.FAILED }
+
+ status.forEach {
+ val result =
+ fragment.shouldShowCompletedDownloadDialog(download, it)
+
+ assertFalse(result)
+ }
+ }
+
+ @Test
+ fun `WHEN the tab is different from the initial one then shouldShowCompletedDownloadDialog will be false`() {
+ every { fragment.getCurrentTab() } returns createTab(id = "1", url = "")
+
+ val download = DownloadState(
+ url = "",
+ sessionId = "2",
+ destinationDirectory = "/",
+ )
+
+ val status = DownloadState.Status.values()
+ .filter { it != DownloadState.Status.COMPLETED && it != DownloadState.Status.FAILED }
+
+ status.forEach {
+ val result =
+ fragment.shouldShowCompletedDownloadDialog(download, it)
+
+ assertFalse(result)
+ }
+ }
+
+ @Test
+ fun `WHEN initializeEngineView is called THEN setDynamicToolbarMaxHeight sets max height to the engine view as a sum of two toolbars heights`() {
+ every { settings.shouldUseFixedTopToolbar } returns false
+ every { settings.isDynamicToolbarEnabled } returns true
+
+ fragment.initializeEngineView(
+ topToolbarHeight = 13,
+ bottomToolbarHeight = 0,
+ )
+ verify { engineView.setDynamicToolbarMaxHeight(13) }
+
+ fragment.initializeEngineView(
+ topToolbarHeight = 0,
+ bottomToolbarHeight = 13,
+ )
+ verify { engineView.setDynamicToolbarMaxHeight(13) }
+
+ fragment.initializeEngineView(
+ topToolbarHeight = 13,
+ bottomToolbarHeight = 13,
+ )
+ verify { engineView.setDynamicToolbarMaxHeight(26) }
+ }
+}
+
+private class TestBaseBrowserFragment : BaseBrowserFragment() {
+ override fun getContextMenuCandidates(
+ context: Context,
+ view: View,
+ ): List<ContextMenuCandidate> {
+ // no-op
+ return emptyList()
+ }
+
+ override fun navToQuickSettingsSheet(tab: SessionState, sitePermissions: SitePermissions?) {
+ // no-op
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/BrowserFragmentTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/BrowserFragmentTest.kt
new file mode 100644
index 0000000000..8c13905920
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/BrowserFragmentTest.kt
@@ -0,0 +1,448 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.browser
+
+import android.content.Context
+import android.view.View
+import androidx.appcompat.content.res.AppCompatResources
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.navigation.NavController
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.mockkStatic
+import io.mockk.spyk
+import io.mockk.unmockkObject
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import mozilla.components.browser.state.action.RestoreCompleteAction
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.SessionState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.browser.toolbar.BrowserToolbar
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.After
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.FeatureFlags
+import org.mozilla.fenix.FenixApplication
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.R
+import org.mozilla.fenix.components.toolbar.BrowserToolbarView
+import org.mozilla.fenix.components.toolbar.ToolbarIntegration
+import org.mozilla.fenix.ext.application
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.onboarding.FenixOnboarding
+import org.mozilla.fenix.theme.ThemeManager
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class BrowserFragmentTest {
+
+ private lateinit var store: BrowserStore
+ private lateinit var testTab: TabSessionState
+ private lateinit var browserFragment: BrowserFragment
+ private lateinit var view: View
+ private lateinit var homeActivity: HomeActivity
+ private lateinit var fenixApplication: FenixApplication
+ private lateinit var context: Context
+ private lateinit var lifecycleOwner: MockedLifecycleOwner
+ private lateinit var navController: NavController
+ private lateinit var onboarding: FenixOnboarding
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Before
+ fun setup() {
+ context = mockk(relaxed = true)
+ fenixApplication = mockk(relaxed = true)
+ every { context.application } returns fenixApplication
+
+ homeActivity = mockk(relaxed = true)
+ view = mockk(relaxed = true)
+ lifecycleOwner = MockedLifecycleOwner(Lifecycle.State.STARTED)
+ navController = mockk(relaxed = true)
+ onboarding = mockk(relaxed = true)
+
+ browserFragment = spyk(BrowserFragment())
+ every { browserFragment.view } returns view
+ every { browserFragment.isAdded } returns true
+ every { browserFragment.browserToolbarView } returns mockk(relaxed = true)
+ every { browserFragment.activity } returns homeActivity
+ every { browserFragment.lifecycle } returns lifecycleOwner.lifecycle
+ every { context.components.fenixOnboarding } returns onboarding
+
+ every { browserFragment.requireContext() } returns context
+ every { browserFragment.initializeUI(any(), any()) } returns mockk()
+ every { browserFragment.fullScreenChanged(any()) } returns Unit
+ every { browserFragment.resumeDownloadDialogState(any(), any(), any(), any()) } returns Unit
+
+ testTab = createTab(url = "https://mozilla.org")
+ store = BrowserStore()
+ every { context.components.core.store } returns store
+
+ mockkObject(FeatureFlags)
+ }
+
+ @After
+ fun tearDown() {
+ unmockkObject(FeatureFlags)
+ }
+
+ @Test
+ fun `GIVEN fragment is added WHEN selected tab changes THEN theme is updated`() {
+ browserFragment.observeTabSelection(store)
+ verify(exactly = 0) { browserFragment.updateThemeForSession(testTab) }
+
+ addAndSelectTab(testTab)
+ verify(exactly = 1) { browserFragment.updateThemeForSession(testTab) }
+ }
+
+ @Test
+ fun `GIVEN fragment is removing WHEN selected tab changes THEN theme is not updated`() {
+ every { browserFragment.isRemoving } returns true
+ browserFragment.observeTabSelection(store)
+
+ addAndSelectTab(testTab)
+ verify(exactly = 0) { browserFragment.updateThemeForSession(testTab) }
+ }
+
+ @Test
+ fun `GIVEN browser UI is not initialized WHEN selected tab changes THEN browser UI is initialized`() {
+ browserFragment.observeTabSelection(store)
+ verify(exactly = 0) { browserFragment.initializeUI(view, testTab) }
+
+ addAndSelectTab(testTab)
+ verify(exactly = 1) { browserFragment.initializeUI(view, testTab) }
+ }
+
+ @Test
+ fun `GIVEN browser UI is initialized WHEN selected tab changes THEN toolbar is expanded`() {
+ browserFragment.browserInitialized = true
+ browserFragment.observeTabSelection(store)
+
+ val toolbar: BrowserToolbarView = mockk(relaxed = true)
+ every { browserFragment.browserToolbarView } returns toolbar
+
+ val newSelectedTab = createTab("https://firefox.com")
+ addAndSelectTab(newSelectedTab)
+ verify(exactly = 1) { toolbar.expand() }
+ }
+
+ @Test
+ fun `GIVEN browser UI is initialized WHEN selected tab changes THEN full screen mode is exited`() {
+ browserFragment.browserInitialized = true
+ browserFragment.observeTabSelection(store)
+
+ val newSelectedTab = createTab("https://firefox.com")
+ addAndSelectTab(newSelectedTab)
+ verify(exactly = 1) { browserFragment.fullScreenChanged(false) }
+ }
+
+ @Test
+ fun `GIVEN browser UI is initialized WHEN selected tab changes THEN download dialog is resumed`() {
+ browserFragment.browserInitialized = true
+ browserFragment.observeTabSelection(store)
+
+ val newSelectedTab = createTab("https://firefox.com")
+ addAndSelectTab(newSelectedTab)
+ verify(exactly = 1) {
+ browserFragment.resumeDownloadDialogState(newSelectedTab.id, store, context, any())
+ }
+ }
+
+ @Test
+ fun `GIVEN tabs are restored WHEN there are no tabs THEN navigate to home`() {
+ browserFragment.observeRestoreComplete(store, navController)
+ store.dispatch(RestoreCompleteAction).joinBlocking()
+
+ verify(exactly = 1) { navController.popBackStack(R.id.homeFragment, false) }
+ }
+
+ @Test
+ fun `GIVEN tabs are restored WHEN there are tabs THEN do not navigate`() {
+ addAndSelectTab(testTab)
+ browserFragment.observeRestoreComplete(store, navController)
+ store.dispatch(RestoreCompleteAction).joinBlocking()
+
+ verify(exactly = 0) { navController.popBackStack(R.id.homeFragment, false) }
+ }
+
+ @Test
+ fun `GIVEN tabs are restored WHEN there is no selected tab THEN navigate to home`() {
+ val store = BrowserStore(initialState = BrowserState(tabs = listOf(testTab)))
+ browserFragment.observeRestoreComplete(store, navController)
+ store.dispatch(RestoreCompleteAction).joinBlocking()
+
+ verify(exactly = 1) { navController.popBackStack(R.id.homeFragment, false) }
+ }
+
+ @Test
+ fun `GIVEN the onboarding is finished WHEN visiting any link THEN the onboarding is not dismissed `() {
+ every { onboarding.userHasBeenOnboarded() } returns true
+
+ browserFragment.observeTabSource(store)
+
+ val newSelectedTab = createTab("any-tab.org")
+ addAndSelectTab(newSelectedTab)
+
+ verify(exactly = 0) { onboarding.finish() }
+ }
+
+ @Test
+ fun `GIVEN the onboarding is not finished WHEN visiting a link THEN the onboarding is dismissed `() {
+ every { onboarding.userHasBeenOnboarded() } returns false
+
+ browserFragment.observeTabSource(store)
+
+ val newSelectedTab = createTab("any-tab.org")
+ addAndSelectTab(newSelectedTab)
+
+ verify(exactly = 1) { onboarding.finish() }
+ }
+
+ @Test
+ fun `GIVEN the onboarding is not finished WHEN visiting an onboarding link THEN the onboarding is not dismissed `() {
+ every { onboarding.userHasBeenOnboarded() } returns false
+
+ browserFragment.observeTabSource(store)
+
+ val newSelectedTab = createTab(BaseBrowserFragment.onboardingLinksList[0])
+ addAndSelectTab(newSelectedTab)
+
+ verify(exactly = 0) { onboarding.finish() }
+ }
+
+ @Test
+ fun `GIVEN the onboarding is not finished WHEN opening a page from another app THEN the onboarding is not dismissed `() {
+ every { onboarding.userHasBeenOnboarded() } returns false
+
+ browserFragment.observeTabSource(store)
+
+ val newSelectedTab1 = createTab("any-tab-1.org", source = SessionState.Source.External.ActionSearch(mockk()))
+ val newSelectedTab2 = createTab("any-tab-2.org", source = SessionState.Source.External.ActionView(mockk()))
+ val newSelectedTab3 = createTab("any-tab-3.org", source = SessionState.Source.External.ActionSend(mockk()))
+ val newSelectedTab4 = createTab("any-tab-4.org", source = SessionState.Source.External.CustomTab(mockk()))
+
+ addAndSelectTab(newSelectedTab1)
+ verify(exactly = 0) { onboarding.finish() }
+
+ addAndSelectTab(newSelectedTab2)
+ verify(exactly = 0) { onboarding.finish() }
+
+ addAndSelectTab(newSelectedTab3)
+ verify(exactly = 0) { onboarding.finish() }
+
+ addAndSelectTab(newSelectedTab4)
+ verify(exactly = 0) { onboarding.finish() }
+ }
+
+ @Test
+ fun `GIVEN the onboarding is not finished WHEN visiting an link after redirect THEN the onboarding is not dismissed `() {
+ every { onboarding.userHasBeenOnboarded() } returns false
+
+ val newSelectedTab: TabSessionState = mockk(relaxed = true)
+ every { newSelectedTab.content.loadRequest?.triggeredByRedirect } returns true
+
+ browserFragment.observeTabSource(store)
+ addAndSelectTab(newSelectedTab)
+
+ verify(exactly = 0) { onboarding.finish() }
+ }
+
+ @Test
+ fun `WHEN isPullToRefreshEnabledInBrowser is disabled THEN pull down refresh is disabled`() {
+ every { context.settings().isPullToRefreshEnabledInBrowser } returns true
+ assertTrue(browserFragment.shouldPullToRefreshBeEnabled(false))
+
+ every { context.settings().isPullToRefreshEnabledInBrowser } returns false
+ assertTrue(!browserFragment.shouldPullToRefreshBeEnabled(false))
+ }
+
+ @Test
+ fun `WHEN in fullscreen THEN pull down refresh is disabled`() {
+ every { context.settings().isPullToRefreshEnabledInBrowser } returns true
+ assertTrue(browserFragment.shouldPullToRefreshBeEnabled(false))
+ assertTrue(!browserFragment.shouldPullToRefreshBeEnabled(true))
+ }
+
+ @Test
+ fun `WHEN fragment is not attached THEN toolbar invalidation does nothing`() {
+ val browserToolbarView: BrowserToolbarView = mockk(relaxed = true)
+ val browserToolbar: BrowserToolbar = mockk(relaxed = true)
+ val toolbarIntegration: ToolbarIntegration = mockk(relaxed = true)
+ every { browserToolbarView.view } returns browserToolbar
+ every { browserToolbarView.toolbarIntegration } returns toolbarIntegration
+ every { browserFragment.context } returns null
+ browserFragment._browserToolbarView = browserToolbarView
+ browserFragment.safeInvalidateBrowserToolbarView()
+
+ verify(exactly = 0) { browserToolbar.invalidateActions() }
+ verify(exactly = 0) { toolbarIntegration.invalidateMenu() }
+ }
+
+ @Test
+ @Suppress("TooGenericExceptionCaught")
+ fun `WHEN fragment is attached and toolbar view is null THEN toolbar invalidation is safe`() {
+ every { browserFragment.context } returns mockk(relaxed = true)
+ try {
+ browserFragment.safeInvalidateBrowserToolbarView()
+ } catch (e: Exception) {
+ fail("Exception thrown when invalidating toolbar")
+ }
+ }
+
+ @Test
+ fun `WHEN fragment and view are attached THEN toolbar invalidation is triggered`() {
+ val browserToolbarView: BrowserToolbarView = mockk(relaxed = true)
+ val browserToolbar: BrowserToolbar = mockk(relaxed = true)
+ val toolbarIntegration: ToolbarIntegration = mockk(relaxed = true)
+ every { browserToolbarView.view } returns browserToolbar
+ every { browserToolbarView.toolbarIntegration } returns toolbarIntegration
+ every { browserFragment.context } returns mockk(relaxed = true)
+ browserFragment._browserToolbarView = browserToolbarView
+ browserFragment.safeInvalidateBrowserToolbarView()
+
+ verify(exactly = 1) { browserToolbar.invalidateActions() }
+ verify(exactly = 1) { toolbarIntegration.invalidateMenu() }
+ }
+
+ @Test
+ fun `WHEN toolbar is initialized THEN onConfigurationChanged sets toolbar actions for size in fragment`() {
+ val browserToolbarView: BrowserToolbarView = mockk(relaxed = true)
+
+ browserFragment._browserToolbarView = null
+ browserFragment.onConfigurationChanged(mockk(relaxed = true))
+ verify(exactly = 0) { browserFragment.onUpdateToolbarForConfigurationChange(any()) }
+ verify(exactly = 0) { browserFragment.updateToolbarActions(any()) }
+
+ browserFragment._browserToolbarView = browserToolbarView
+ browserFragment.onConfigurationChanged(mockk(relaxed = true))
+ verify(exactly = 1) { browserFragment.onUpdateToolbarForConfigurationChange(any()) }
+ verify(exactly = 1) { browserFragment.updateToolbarActions(any()) }
+ }
+
+ @Test
+ fun `WHEN fragment configuration changed THEN menu is dismissed`() {
+ val browserToolbarView: BrowserToolbarView = mockk(relaxed = true)
+ every { browserFragment.context } returns null
+ browserFragment._browserToolbarView = browserToolbarView
+
+ browserFragment.onConfigurationChanged(mockk(relaxed = true))
+
+ verify(exactly = 1) { browserToolbarView.dismissMenu() }
+ }
+
+ @Test
+ fun `WHEN fragment configuration screen size changes between tablet and mobile size THEN tablet action items added and removed`() {
+ val browserToolbarView: BrowserToolbarView = mockk(relaxed = true)
+ val browserToolbar: BrowserToolbar = mockk(relaxed = true)
+ browserFragment._browserToolbarView = browserToolbarView
+ every { browserFragment.browserToolbarView.view } returns browserToolbar
+
+ mockkObject(ThemeManager.Companion)
+ every { ThemeManager.resolveAttribute(any(), context) } returns mockk(relaxed = true)
+
+ mockkStatic(AppCompatResources::class)
+ every { AppCompatResources.getDrawable(context, any()) } returns mockk()
+
+ every { browserFragment.resources.getBoolean(R.bool.tablet) } returns true
+ browserFragment.onConfigurationChanged(mockk(relaxed = true))
+ verify(exactly = 3) { browserToolbar.addNavigationAction(any()) }
+
+ every { browserFragment.resources.getBoolean(R.bool.tablet) } returns false
+ browserFragment.onConfigurationChanged(mockk(relaxed = true))
+ verify(exactly = 3) { browserToolbar.removeNavigationAction(any()) }
+
+ unmockkObject(ThemeManager.Companion)
+ unmockkStatic(AppCompatResources::class)
+ }
+
+ @Test
+ fun `WHEN fragment configuration change enables tablet size twice THEN tablet action items are only added once`() {
+ val browserToolbarView: BrowserToolbarView = mockk(relaxed = true)
+ val browserToolbar: BrowserToolbar = mockk(relaxed = true)
+ browserFragment._browserToolbarView = browserToolbarView
+ every { browserFragment.browserToolbarView.view } returns browserToolbar
+
+ mockkObject(ThemeManager.Companion)
+ every { ThemeManager.resolveAttribute(any(), context) } returns mockk(relaxed = true)
+
+ mockkStatic(AppCompatResources::class)
+ every { AppCompatResources.getDrawable(context, any()) } returns mockk()
+
+ every { browserFragment.resources.getBoolean(R.bool.tablet) } returns true
+ browserFragment.onConfigurationChanged(mockk(relaxed = true))
+ verify(exactly = 3) { browserToolbar.addNavigationAction(any()) }
+
+ browserFragment.onConfigurationChanged(mockk(relaxed = true))
+ verify(exactly = 3) { browserToolbar.addNavigationAction(any()) }
+
+ unmockkObject(ThemeManager.Companion)
+ unmockkStatic(AppCompatResources::class)
+ }
+
+ @Test
+ fun `WHEN fragment configuration change sets mobile size twice THEN tablet action items are not added or removed`() {
+ val browserToolbarView: BrowserToolbarView = mockk(relaxed = true)
+ val browserToolbar: BrowserToolbar = mockk(relaxed = true)
+ browserFragment._browserToolbarView = browserToolbarView
+ every { browserFragment.browserToolbarView.view } returns browserToolbar
+
+ mockkObject(ThemeManager.Companion)
+ every { ThemeManager.resolveAttribute(any(), context) } returns mockk(relaxed = true)
+
+ mockkStatic(AppCompatResources::class)
+ every { AppCompatResources.getDrawable(context, any()) } returns mockk()
+
+ every { browserFragment.resources.getBoolean(R.bool.tablet) } returns false
+ browserFragment.onConfigurationChanged(mockk(relaxed = true))
+ verify(exactly = 0) { browserToolbar.addNavigationAction(any()) }
+ verify(exactly = 0) { browserToolbar.removeNavigationAction(any()) }
+
+ browserFragment.onConfigurationChanged(mockk(relaxed = true))
+ verify(exactly = 0) { browserToolbar.addNavigationAction(any()) }
+ verify(exactly = 0) { browserToolbar.removeNavigationAction(any()) }
+
+ unmockkObject(ThemeManager.Companion)
+ unmockkStatic(AppCompatResources::class)
+ }
+
+ private fun addAndSelectTab(tab: TabSessionState) {
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ store.dispatch(TabListAction.SelectTabAction(tab.id)).joinBlocking()
+ }
+
+ internal class MockedLifecycleOwner(initialState: Lifecycle.State) : LifecycleOwner {
+ override val lifecycle: Lifecycle = LifecycleRegistry(this).apply {
+ currentState = initialState
+ }
+ }
+
+ @Test
+ fun `WHEN updating the last browse activity THEN update the associated preference`() {
+ val settings: Settings = mockk(relaxed = true)
+
+ every { browserFragment.context } returns context
+ every { context.settings() } returns settings
+
+ browserFragment.updateLastBrowseActivity()
+
+ verify(exactly = 1) { settings.lastBrowseActivity = any() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/FenixSnackbarDelegateTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/FenixSnackbarDelegateTest.kt
new file mode 100644
index 0000000000..977a0fe942
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/FenixSnackbarDelegateTest.kt
@@ -0,0 +1,124 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.browser
+
+import android.view.View
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.unmockkObject
+import io.mockk.verify
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.R
+import org.mozilla.fenix.components.FenixSnackbar
+import org.mozilla.fenix.components.FenixSnackbar.Companion.LENGTH_SHORT
+import org.mozilla.fenix.helpers.MockkRetryTestRule
+
+class FenixSnackbarDelegateTest {
+
+ @MockK private lateinit var view: View
+
+ @MockK(relaxed = true)
+ private lateinit var snackbar: FenixSnackbar
+ private lateinit var delegate: FenixSnackbarDelegate
+
+ @get:Rule
+ val mockkRule = MockkRetryTestRule()
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ mockkObject(FenixSnackbar.Companion)
+
+ delegate = FenixSnackbarDelegate(view)
+ every {
+ FenixSnackbar.make(view, LENGTH_SHORT, isDisplayedWithBrowserToolbar = true)
+ } returns snackbar
+ every { snackbar.setText(any()) } returns snackbar
+ every { snackbar.setAction(any(), any()) } returns snackbar
+ every { view.context.getString(R.string.app_name) } returns "Firefox"
+ every { view.context.getString(R.string.edit_2) } returns "Edit password"
+ }
+
+ @After
+ fun teardown() {
+ unmockkObject(FenixSnackbar.Companion)
+ }
+
+ @Test
+ fun `show with no listener nor action`() {
+ delegate.show(
+ snackBarParentView = mockk(),
+ text = R.string.app_name,
+ duration = 0,
+ action = 0,
+ listener = null,
+ )
+
+ verify { snackbar.setText("Firefox") }
+ verify(exactly = 0) { snackbar.setAction(any(), any()) }
+ verify { snackbar.show() }
+ }
+
+ @Test
+ fun `show with listener but no action`() {
+ delegate.show(
+ snackBarParentView = mockk(),
+ text = R.string.app_name,
+ duration = 0,
+ action = 0,
+ listener = {},
+ )
+
+ verify { snackbar.setText("Firefox") }
+ verify(exactly = 0) { snackbar.setAction(any(), any()) }
+ verify { snackbar.show() }
+ }
+
+ @Test
+ fun `show with action but no listener`() {
+ delegate.show(
+ snackBarParentView = mockk(),
+ text = R.string.app_name,
+ duration = 0,
+ action = R.string.edit_2,
+ listener = null,
+ )
+
+ verify { snackbar.setText("Firefox") }
+ verify(exactly = 0) { snackbar.setAction(any(), any()) }
+ verify { snackbar.show() }
+ }
+
+ @Test
+ fun `show with listener and action`() {
+ val listener = mockk<(View) -> Unit>(relaxed = true)
+ delegate.show(
+ snackBarParentView = mockk(),
+ text = R.string.app_name,
+ duration = 0,
+ action = R.string.edit_2,
+ listener = listener,
+ )
+
+ verify { snackbar.setText("Firefox") }
+ verify {
+ snackbar.setAction(
+ "Edit password",
+ withArg {
+ verify(exactly = 0) { listener(view) }
+ it.invoke()
+ verify { listener(view) }
+ },
+ )
+ }
+ verify { snackbar.show() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/OpenInAppOnboardingObserverTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/OpenInAppOnboardingObserverTest.kt
new file mode 100644
index 0000000000..c8da4b7c15
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/OpenInAppOnboardingObserverTest.kt
@@ -0,0 +1,202 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.browser
+
+import android.content.Context
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.navigation.NavController
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.feature.app.links.AppLinksUseCases
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.After
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.browser.infobanner.DynamicInfoBanner
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class OpenInAppOnboardingObserverTest {
+
+ private lateinit var store: BrowserStore
+ private lateinit var lifecycleOwner: MockedLifecycleOwner
+ private lateinit var openInAppOnboardingObserver: OpenInAppOnboardingObserver
+ private lateinit var navigationController: NavController
+ private lateinit var settings: Settings
+ private lateinit var appLinksUseCases: AppLinksUseCases
+ private lateinit var context: Context
+ private lateinit var container: ViewGroup
+ private lateinit var infoBanner: DynamicInfoBanner
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Before
+ fun setUp() {
+ store = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab(url = "https://www.mozilla.org", id = "1"),
+ ),
+ selectedTabId = "1",
+ ),
+ )
+ lifecycleOwner = MockedLifecycleOwner(Lifecycle.State.STARTED)
+ navigationController = mockk(relaxed = true)
+ settings = mockk(relaxed = true)
+ appLinksUseCases = mockk(relaxed = true)
+ container = mockk(relaxed = true)
+ context = mockk(relaxed = true)
+ infoBanner = mockk(relaxed = true)
+ openInAppOnboardingObserver = spyk(
+ OpenInAppOnboardingObserver(
+ context = context,
+ store = store,
+ lifecycleOwner = lifecycleOwner,
+ navController = navigationController,
+ settings = settings,
+ appLinksUseCases = appLinksUseCases,
+ container = container,
+ shouldScrollWithTopToolbar = true,
+ ),
+ )
+ every { openInAppOnboardingObserver.createInfoBanner() } returns infoBanner
+ }
+
+ @After
+ fun teardown() {
+ openInAppOnboardingObserver.stop()
+ }
+
+ @Test
+ fun `GIVEN user configured to open links in external app WHEN page finishes loading THEN do not show banner`() {
+ every { settings.shouldOpenLinksInApp() } returns true
+ every { settings.shouldShowOpenInAppCfr } returns true
+ every { appLinksUseCases.appLinkRedirect.invoke(any()).hasExternalApp() } returns true
+ store.dispatch(ContentAction.UpdateLoadingStateAction("1", true)).joinBlocking()
+
+ openInAppOnboardingObserver.start()
+ store.dispatch(ContentAction.UpdateLoadingStateAction("1", false)).joinBlocking()
+ verify(exactly = 0) { infoBanner.showBanner() }
+ }
+
+ @Test
+ fun `GIVEN user has not configured to open links in external app WHEN page finishes loading THEN show banner`() {
+ every { settings.shouldOpenLinksInApp() } returns false
+ every { settings.shouldShowOpenInAppCfr } returns true
+ every { appLinksUseCases.appLinkRedirect.invoke(any()).hasExternalApp() } returns true
+ store.dispatch(ContentAction.UpdateLoadingStateAction("1", true)).joinBlocking()
+
+ openInAppOnboardingObserver.start()
+
+ store.dispatch(ContentAction.UpdateLoadingStateAction("1", false)).joinBlocking()
+ verify(exactly = 1) { infoBanner.showBanner() }
+ }
+
+ @Test
+ fun `GIVEN banner was already displayed WHEN page finishes loading THEN do not show banner`() {
+ every { settings.openLinksInExternalApp } returns "never"
+ every { settings.shouldShowOpenInAppCfr } returns false
+ every { appLinksUseCases.appLinkRedirect.invoke(any()).hasExternalApp() } returns true
+ store.dispatch(ContentAction.UpdateLoadingStateAction("1", true)).joinBlocking()
+
+ openInAppOnboardingObserver.start()
+ store.dispatch(ContentAction.UpdateLoadingStateAction("1", false)).joinBlocking()
+ verify(exactly = 0) { infoBanner.showBanner() }
+ }
+
+ @Test
+ fun `GIVEN banner should be displayed WHEN no application found THEN do not show banner`() {
+ every { settings.openLinksInExternalApp } returns "never"
+ every { settings.shouldShowOpenInAppCfr } returns true
+ every { appLinksUseCases.appLinkRedirect.invoke(any()).hasExternalApp() } returns false
+ store.dispatch(ContentAction.UpdateLoadingStateAction("1", true)).joinBlocking()
+
+ openInAppOnboardingObserver.start()
+
+ store.dispatch(ContentAction.UpdateLoadingStateAction("1", false)).joinBlocking()
+ verify(exactly = 0) { infoBanner.showBanner() }
+ }
+
+ @Test
+ fun `GIVEN banner is displayed WHEN user navigates to different domain THEN banner is dismissed`() {
+ every { settings.openLinksInExternalApp } returns "never"
+ every { settings.shouldShowOpenInAppCfr } returns true
+ every { appLinksUseCases.appLinkRedirect.invoke(any()).hasExternalApp() } returns true
+
+ store.dispatch(ContentAction.UpdateLoadingStateAction("1", true)).joinBlocking()
+
+ openInAppOnboardingObserver.start()
+
+ store.dispatch(ContentAction.UpdateLoadingStateAction("1", false)).joinBlocking()
+ verify(exactly = 1) { infoBanner.showBanner() }
+ verify(exactly = 0) { infoBanner.dismiss() }
+
+ store.dispatch(ContentAction.UpdateUrlAction("1", "https://www.mozilla.org/en-US/")).joinBlocking()
+ verify(exactly = 0) { infoBanner.dismiss() }
+
+ store.dispatch(ContentAction.UpdateUrlAction("1", "https://www.firefox.com")).joinBlocking()
+ verify(exactly = 1) { infoBanner.dismiss() }
+ }
+
+ @Test
+ fun `GIVEN a observer WHEN createInfoBanner() THEN the scrollWithTopToolbar is passed to the DynamicInfoBanner`() {
+ // Mockk currently doesn't support verifying constructor parameters
+ // But we can check the values found in the constructed objects
+
+ openInAppOnboardingObserver = spyk(
+ OpenInAppOnboardingObserver(
+ testContext,
+ mockk(),
+ mockk(),
+ mockk(),
+ mockk(),
+ mockk(),
+ FrameLayout(testContext),
+ shouldScrollWithTopToolbar = true,
+ ),
+ )
+ val banner1 = openInAppOnboardingObserver.createInfoBanner()
+ assertTrue(banner1.shouldScrollWithTopToolbar)
+
+ openInAppOnboardingObserver = spyk(
+ OpenInAppOnboardingObserver(
+ testContext,
+ mockk(),
+ mockk(),
+ mockk(),
+ mockk(),
+ mockk(),
+ FrameLayout(testContext),
+ shouldScrollWithTopToolbar = false,
+ ),
+ )
+ val banner2 = openInAppOnboardingObserver.createInfoBanner()
+ assertFalse(banner2.shouldScrollWithTopToolbar)
+ }
+
+ internal class MockedLifecycleOwner(initialState: Lifecycle.State) : LifecycleOwner {
+ override val lifecycle: Lifecycle = LifecycleRegistry(this).apply {
+ currentState = initialState
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/StandardSnackbarErrorBindingTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/StandardSnackbarErrorBindingTest.kt
new file mode 100644
index 0000000000..f102eafbb7
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/StandardSnackbarErrorBindingTest.kt
@@ -0,0 +1,137 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.browser
+
+import android.app.Activity
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.content.res.AppCompatResources
+import androidx.core.content.ContextCompat
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.mockkStatic
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.FenixSnackbar
+import org.mozilla.fenix.components.appstate.AppAction
+import org.mozilla.fenix.ext.getRootView
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class StandardSnackbarErrorBindingTest {
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ private lateinit var activity: Activity
+ private lateinit var snackbar: FenixSnackbar
+ private lateinit var rootView: View
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ mockkObject(FenixSnackbar)
+
+ mockkStatic(AppCompatResources::class)
+ every { AppCompatResources.getDrawable(any(), any()) } returns mockk(relaxed = true)
+
+ snackbar = mockk(relaxed = true)
+ every { FenixSnackbar.make(any(), any(), any(), any()) } returns snackbar
+ rootView = mockk<ViewGroup>(relaxed = true)
+ activity = mockk(relaxed = true) {
+ every { findViewById<View>(android.R.id.content) } returns rootView
+ every { getRootView() } returns rootView
+ }
+ }
+
+ @After
+ fun teardown() {
+ unmockkStatic(AppCompatResources::class)
+ }
+
+ @Test
+ fun `WHEN show standard snackbar error action dispatched THEN fenix snackbar should appear`() {
+ val appStore = AppStore()
+ val standardSnackbarError = StandardSnackbarErrorBinding(
+ activity,
+ appStore,
+ )
+
+ standardSnackbarError.start()
+ appStore.dispatch(
+ AppAction.UpdateStandardSnackbarErrorAction(
+ StandardSnackbarError(
+ testContext.getString(R.string.unable_to_save_to_pdf_error),
+ ),
+ ),
+ )
+ appStore.waitUntilIdle()
+
+ verify {
+ snackbar.setText(testContext.getString(R.string.unable_to_save_to_pdf_error))
+ snackbar.setButtonTextColor(
+ ContextCompat.getColor(
+ activity,
+ R.color.fx_mobile_text_color_primary,
+ ),
+ )
+ snackbar.setBackground(
+ any(),
+ )
+ snackbar.setSnackBarTextColor(
+ ContextCompat.getColor(
+ activity,
+ R.color.fx_mobile_text_color_warning,
+ ),
+ )
+ snackbar.setAction(
+ text = activity.getString(R.string.standard_snackbar_error_dismiss),
+ any(),
+ )
+ snackbar.show()
+ }
+ }
+
+ @Test
+ fun `WHEN show standard snackbar error action dispatched and binding is stopped THEN fenix snackbar should appear when binding is again started`() {
+ val appStore = AppStore()
+ val standardSnackbarError = StandardSnackbarErrorBinding(
+ activity,
+ appStore,
+ )
+
+ standardSnackbarError.start()
+ appStore.dispatch(
+ AppAction.UpdateStandardSnackbarErrorAction(
+ StandardSnackbarError(
+ testContext.getString(R.string.unable_to_save_to_pdf_error),
+ ),
+ ),
+ )
+ appStore.waitUntilIdle()
+
+ standardSnackbarError.stop()
+
+ standardSnackbarError.start()
+
+ verify {
+ snackbar.show()
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/TranslationsBindingTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/TranslationsBindingTest.kt
new file mode 100644
index 0000000000..f92d8f22e2
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/TranslationsBindingTest.kt
@@ -0,0 +1,257 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.browser
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.state.action.TranslationsAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.ReaderState
+import mozilla.components.browser.state.state.TranslationsBrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.translate.DetectedLanguages
+import mozilla.components.concept.engine.translate.Language
+import mozilla.components.concept.engine.translate.TranslationEngineState
+import mozilla.components.concept.engine.translate.TranslationOperation
+import mozilla.components.concept.engine.translate.TranslationPair
+import mozilla.components.concept.engine.translate.TranslationSupport
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+class TranslationsBindingTest {
+ @get:Rule
+ val coroutineRule = MainCoroutineRule()
+
+ lateinit var browserStore: BrowserStore
+
+ private val tabId = "1"
+ private val tab = createTab(url = tabId, id = tabId)
+ private val onTranslationsActionUpdated: (TranslationsIconState) -> Unit = spy()
+
+ private val onShowTranslationsDialog: () -> Unit = spy()
+
+ @Test
+ fun `GIVEN translationState WHEN translation status isTranslated THEN invoke onTranslationsActionUpdated callback`() =
+ runTestOnMain {
+ val englishLanguage = Language("en", "English")
+ val spanishLanguage = Language("es", "Spanish")
+
+ browserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tabId,
+ translationEngine = TranslationsBrowserState(isEngineSupported = true),
+ ),
+ )
+
+ val binding = TranslationsBinding(
+ browserStore = browserStore,
+ onTranslationsActionUpdated = onTranslationsActionUpdated,
+ onShowTranslationsDialog = {},
+ )
+ binding.start()
+
+ val detectedLanguages = DetectedLanguages(
+ documentLangTag = englishLanguage.code,
+ supportedDocumentLang = true,
+ userPreferredLangTag = spanishLanguage.code,
+ )
+
+ val translationEngineState = TranslationEngineState(
+ detectedLanguages = detectedLanguages,
+ error = null,
+ isEngineReady = true,
+ requestedTranslationPair = TranslationPair(
+ fromLanguage = englishLanguage.code,
+ toLanguage = spanishLanguage.code,
+ ),
+ )
+
+ val supportLanguages = TranslationSupport(
+ fromLanguages = listOf(englishLanguage),
+ toLanguages = listOf(spanishLanguage),
+ )
+
+ browserStore.dispatch(
+ TranslationsAction.SetSupportedLanguagesAction(
+ supportedLanguages = supportLanguages,
+ ),
+ ).joinBlocking()
+
+ browserStore.dispatch(
+ TranslationsAction.TranslateStateChangeAction(
+ tabId = tabId,
+ translationEngineState = translationEngineState,
+ ),
+ ).joinBlocking()
+
+ browserStore.dispatch(
+ TranslationsAction.TranslateSuccessAction(
+ tabId = tab.id,
+ operation = TranslationOperation.TRANSLATE,
+ ),
+ ).joinBlocking()
+
+ verify(onTranslationsActionUpdated).invoke(
+ TranslationsIconState(
+ isVisible = true,
+ isTranslated = true,
+ fromSelectedLanguage = englishLanguage,
+ toSelectedLanguage = spanishLanguage,
+ ),
+ )
+ }
+
+ @Test
+ fun `GIVEN translationState WHEN translation status isExpectedTranslate THEN invoke onTranslationsActionUpdated callback`() =
+ runTestOnMain {
+ browserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tabId,
+ translationEngine = TranslationsBrowserState(isEngineSupported = true),
+ ),
+ )
+
+ val binding = TranslationsBinding(
+ browserStore = browserStore,
+ onTranslationsActionUpdated = onTranslationsActionUpdated,
+ onShowTranslationsDialog = {},
+ )
+ binding.start()
+
+ browserStore.dispatch(
+ TranslationsAction.TranslateExpectedAction(
+ tabId = tabId,
+ ),
+ ).joinBlocking()
+
+ verify(onTranslationsActionUpdated).invoke(
+ TranslationsIconState(
+ isVisible = true,
+ isTranslated = false,
+ ),
+ )
+ }
+
+ @Test
+ fun `GIVEN translationState WHEN translation status is not isExpectedTranslate or isTranslated THEN invoke onTranslationsActionUpdated callback`() =
+ runTestOnMain {
+ browserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tabId,
+ ),
+ )
+
+ val binding = TranslationsBinding(
+ browserStore = browserStore,
+ onTranslationsActionUpdated = onTranslationsActionUpdated,
+ onShowTranslationsDialog = {},
+ )
+ binding.start()
+
+ verify(onTranslationsActionUpdated).invoke(
+ TranslationsIconState(
+ isVisible = false,
+ isTranslated = false,
+ ),
+ )
+ }
+
+ @Test
+ fun `GIVEN translationState WHEN translation state isOfferTranslate is true THEN invoke onShowTranslationsDialog callback`() =
+ runTestOnMain {
+ browserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tabId,
+ translationEngine = TranslationsBrowserState(isEngineSupported = true),
+ ),
+ )
+
+ val binding = TranslationsBinding(
+ browserStore = browserStore,
+ onTranslationsActionUpdated = onTranslationsActionUpdated,
+ onShowTranslationsDialog = onShowTranslationsDialog,
+ )
+ binding.start()
+
+ browserStore.dispatch(
+ TranslationsAction.TranslateOfferAction(
+ tabId = tab.id,
+ isOfferTranslate = true,
+ ),
+ ).joinBlocking()
+
+ verify(onShowTranslationsDialog).invoke()
+ }
+
+ @Test
+ fun `GIVEN translationState WHEN readerState is active THEN invoke onTranslationsActionUpdated callback`() =
+ runTestOnMain {
+ val tabReaderStateActive = createTab(
+ "https://www.firefox.com",
+ id = "test-tab",
+ readerState = ReaderState(active = true),
+ )
+ browserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(tabReaderStateActive),
+ selectedTabId = tabReaderStateActive.id,
+ ),
+ )
+
+ val binding = TranslationsBinding(
+ browserStore = browserStore,
+ onTranslationsActionUpdated = onTranslationsActionUpdated,
+ onShowTranslationsDialog = onShowTranslationsDialog,
+ )
+ binding.start()
+
+ verify(onTranslationsActionUpdated).invoke(
+ TranslationsIconState(
+ isVisible = false,
+ isTranslated = false,
+ ),
+ )
+ }
+
+ @Test
+ fun `GIVEN translationState WHEN translation state isOfferTranslate is false THEN do not invoke onShowTranslationsDialog callback`() =
+ runTestOnMain {
+ browserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tabId,
+ translationEngine = TranslationsBrowserState(isEngineSupported = true),
+ ),
+ )
+
+ val binding = TranslationsBinding(
+ browserStore = browserStore,
+ onTranslationsActionUpdated = onTranslationsActionUpdated,
+ onShowTranslationsDialog = onShowTranslationsDialog,
+ )
+ binding.start()
+
+ browserStore.dispatch(
+ TranslationsAction.TranslateOfferAction(
+ tabId = tab.id,
+ isOfferTranslate = false,
+ ),
+ ).joinBlocking()
+
+ verify(onShowTranslationsDialog, never()).invoke()
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/browsingmode/DefaultBrowsingModeManagerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/browsingmode/DefaultBrowsingModeManagerTest.kt
new file mode 100644
index 0000000000..869b16ad97
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/browsingmode/DefaultBrowsingModeManagerTest.kt
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.browser.browsingmode
+
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.verify
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.helpers.MockkRetryTestRule
+import org.mozilla.fenix.utils.Settings
+
+class DefaultBrowsingModeManagerTest {
+
+ @MockK lateinit var settings: Settings
+
+ @MockK(relaxed = true)
+ lateinit var callback: (BrowsingMode) -> Unit
+ lateinit var manager: BrowsingModeManager
+
+ private val initMode = BrowsingMode.Normal
+
+ @get:Rule
+ val mockkRule = MockkRetryTestRule()
+
+ @Before
+ fun before() {
+ MockKAnnotations.init(this)
+
+ manager = DefaultBrowsingModeManager(initMode, settings, callback)
+ every { settings.lastKnownMode = any() } just Runs
+ }
+
+ @Test
+ fun `WHEN mode is updated THEN callback is invoked`() {
+ verify(exactly = 0) { callback.invoke(any()) }
+
+ manager.mode = BrowsingMode.Private
+ manager.mode = BrowsingMode.Private
+ manager.mode = BrowsingMode.Private
+
+ verify(exactly = 3) { callback.invoke(BrowsingMode.Private) }
+
+ manager.mode = BrowsingMode.Normal
+ manager.mode = BrowsingMode.Normal
+
+ verify(exactly = 2) { callback.invoke(BrowsingMode.Normal) }
+ }
+
+ @Test
+ fun `WHEN mode is updated THEN it should be returned from get`() {
+ assertEquals(BrowsingMode.Normal, manager.mode)
+
+ manager.mode = BrowsingMode.Private
+ assertEquals(BrowsingMode.Private, manager.mode)
+ verify { settings.lastKnownMode = BrowsingMode.Private }
+
+ manager.mode = BrowsingMode.Normal
+ assertEquals(BrowsingMode.Normal, manager.mode)
+ verify { settings.lastKnownMode = BrowsingMode.Normal }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/browsingmode/SimpleBrowsingModeManager.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/browsingmode/SimpleBrowsingModeManager.kt
new file mode 100644
index 0000000000..cb0ef51f1e
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/browsingmode/SimpleBrowsingModeManager.kt
@@ -0,0 +1,9 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.browser.browsingmode
+
+data class SimpleBrowsingModeManager(
+ override var mode: BrowsingMode,
+) : BrowsingModeManager
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/infobanner/DynamicInfoBannerBehaviorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/infobanner/DynamicInfoBannerBehaviorTest.kt
new file mode 100644
index 0000000000..bb2859502d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/infobanner/DynamicInfoBannerBehaviorTest.kt
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.browser.infobanner
+
+import android.view.View
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.browser.toolbar.BrowserToolbar
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class DynamicInfoBannerBehaviorTest {
+ @Test
+ fun `layoutDependsOn should not do anything if not for BrowserToolbar as a dependency`() {
+ val behavior = spyk(DynamicInfoBannerBehavior(mockk(), null))
+
+ behavior.layoutDependsOn(mockk(), mockk(), mockk())
+
+ verify(exactly = 0) { behavior.toolbarHeight }
+ verify(exactly = 0) { behavior.toolbarHeight = any() }
+ verify(exactly = 0) { behavior.setBannerYTranslation(any(), any()) }
+ }
+
+ @Test
+ fun `layoutDependsOn should update toolbarHeight and translate the banner`() {
+ val behavior = spyk(DynamicInfoBannerBehavior(mockk(), null))
+ val banner: View = mockk(relaxed = true)
+ val toolbar: BrowserToolbar = mockk {
+ every { height } returns 99
+ every { translationY } returns -33f
+ }
+
+ assertEquals(0, behavior.toolbarHeight)
+
+ behavior.layoutDependsOn(mockk(), banner, toolbar)
+
+ assertEquals(99, behavior.toolbarHeight)
+ verify { behavior.setBannerYTranslation(banner, -33f) }
+ }
+
+ @Test
+ fun `onDependentViewChanged should translate the banner`() {
+ val behavior = spyk(DynamicInfoBannerBehavior(mockk(), null))
+ val banner: View = mockk(relaxed = true)
+ val toolbar: BrowserToolbar = mockk {
+ every { height } returns 50
+ every { translationY } returns -23f
+ }
+
+ behavior.layoutDependsOn(mockk(), banner, toolbar)
+
+ verify { behavior.setBannerYTranslation(banner, -23f) }
+ }
+
+ @Test
+ fun `setBannerYTranslation should set banner translation to be toolbarHeight + it's translation`() {
+ val behavior = spyk(DynamicInfoBannerBehavior(mockk(), null))
+ val banner: View = mockk(relaxed = true)
+ behavior.toolbarHeight = 30
+
+ behavior.setBannerYTranslation(banner, -20f)
+
+ verify { banner.translationY = 10f }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/infobanner/DynamicInfoBannerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/infobanner/DynamicInfoBannerTest.kt
new file mode 100644
index 0000000000..71f8ae787c
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/infobanner/DynamicInfoBannerTest.kt
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.browser.infobanner
+
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class DynamicInfoBannerTest {
+ @Test
+ fun `showBanner should set DynamicInfoBannerBehavior as behavior if scrollWithTopToolbar`() {
+ every { testContext.components.settings } returns mockk(relaxed = true)
+ val banner = spyk(
+ DynamicInfoBanner(
+ testContext,
+ CoordinatorLayout(testContext),
+ true,
+ "",
+ "",
+ ),
+ )
+
+ banner.showBanner()
+
+ assertTrue((banner.binding.root.layoutParams as CoordinatorLayout.LayoutParams).behavior is DynamicInfoBannerBehavior)
+ }
+
+ @Test
+ fun `showBanner should not set a behavior if not scrollWithTopToolbar`() {
+ every { testContext.components.settings } returns mockk(relaxed = true)
+ val banner = spyk(
+ DynamicInfoBanner(
+ testContext,
+ CoordinatorLayout(testContext),
+ false,
+ "",
+ "",
+ ),
+ )
+
+ banner.showBanner()
+
+ assertNull((banner.binding.root.layoutParams as CoordinatorLayout.LayoutParams).behavior)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/readermode/DefaultReaderModeControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/readermode/DefaultReaderModeControllerTest.kt
new file mode 100644
index 0000000000..ba12760445
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/readermode/DefaultReaderModeControllerTest.kt
@@ -0,0 +1,143 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.browser.readermode
+
+import android.content.res.ColorStateList
+import android.view.View
+import android.widget.Button
+import android.widget.RadioButton
+import io.mockk.Called
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import io.mockk.verifyAll
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.feature.readerview.ReaderViewFeature
+import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class DefaultReaderModeControllerTest {
+
+ private lateinit var readerViewFeature: ReaderViewFeature
+ private lateinit var featureWrapper: ViewBoundFeatureWrapper<ReaderViewFeature>
+ private lateinit var readerViewControlsBar: View
+ private lateinit var onReaderModeChanged: () -> Unit
+
+ @Before
+ fun setup() {
+ val tab = createTab("https://mozilla.org")
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ ),
+ )
+ readerViewFeature = spyk(ReaderViewFeature(testContext, mockk(), store, mockk()))
+
+ featureWrapper = ViewBoundFeatureWrapper(
+ feature = readerViewFeature,
+ owner = mockk(relaxed = true),
+ view = mockk(relaxed = true),
+ )
+ readerViewControlsBar = mockk(relaxed = true)
+ onReaderModeChanged = mockk(relaxed = true)
+
+ every { readerViewFeature.hideReaderView() } returns Unit
+ every { readerViewFeature.showReaderView() } returns Unit
+ every { readerViewFeature.showControls() } returns Unit
+ every { readerViewFeature.hideControls() } returns Unit
+ }
+
+ @Test
+ fun testHideReaderView() {
+ val controller = DefaultReaderModeController(
+ featureWrapper,
+ readerViewControlsBar,
+ onReaderModeChanged = onReaderModeChanged,
+ )
+ controller.hideReaderView()
+ verify { readerViewFeature.hideReaderView() }
+ verify { readerViewFeature.hideControls() }
+ verify { onReaderModeChanged.invoke() }
+ }
+
+ @Test
+ fun testShowReaderView() {
+ val controller = DefaultReaderModeController(
+ featureWrapper,
+ readerViewControlsBar,
+ onReaderModeChanged = onReaderModeChanged,
+ )
+ controller.showReaderView()
+ verify { readerViewFeature.showReaderView() }
+ verify { onReaderModeChanged.invoke() }
+ }
+
+ @Test
+ fun testShowControlsNormalTab() {
+ val controller = DefaultReaderModeController(
+ featureWrapper,
+ readerViewControlsBar,
+ isPrivate = false,
+ )
+
+ controller.showControls()
+ verify { readerViewFeature.showControls() }
+ verify { readerViewControlsBar wasNot Called }
+ }
+
+ @Test
+ fun testShowControlsPrivateTab() {
+ val controller = spyk(
+ DefaultReaderModeController(
+ featureWrapper,
+ readerViewControlsBar,
+ isPrivate = true,
+ ),
+ )
+
+ val privateButtonColor = mockk<ColorStateList>()
+ val privateRadioButtonColor = mockk<ColorStateList>()
+
+ every { controller.privateButtonColor } returns privateButtonColor
+ every { controller.privateRadioButtonColor } returns privateRadioButtonColor
+
+ val decrease = mockk<Button>(relaxUnitFun = true)
+ val increase = mockk<Button>(relaxUnitFun = true)
+ val serif = mockk<RadioButton>(relaxUnitFun = true)
+ val sansSerif = mockk<RadioButton>(relaxUnitFun = true)
+
+ every {
+ readerViewControlsBar.findViewById<Button>(R.id.mozac_feature_readerview_font_size_decrease)
+ } returns decrease
+ every {
+ readerViewControlsBar.findViewById<Button>(R.id.mozac_feature_readerview_font_size_increase)
+ } returns increase
+ every {
+ readerViewControlsBar.findViewById<RadioButton>(R.id.mozac_feature_readerview_font_serif)
+ } returns serif
+ every {
+ readerViewControlsBar.findViewById<RadioButton>(R.id.mozac_feature_readerview_font_sans_serif)
+ } returns sansSerif
+
+ controller.showControls()
+ verify { readerViewFeature.showControls() }
+ verifyAll {
+ decrease.setTextColor(privateButtonColor)
+ increase.setTextColor(privateButtonColor)
+ serif.setTextColor(privateRadioButtonColor)
+ sansSerif.setTextColor(privateRadioButtonColor)
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/tabstrip/TabStripStateTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/tabstrip/TabStripStateTest.kt
new file mode 100644
index 0000000000..b343728dc2
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/tabstrip/TabStripStateTest.kt
@@ -0,0 +1,293 @@
+package org.mozilla.fenix.browser.tabstrip
+
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class TabStripStateTest {
+
+ @Test
+ fun `WHEN browser state tabs is empty THEN tabs strip state tabs is empty`() {
+ val browserState = BrowserState(tabs = emptyList())
+ val actual = browserState.toTabStripState(isSelectDisabled = false, isPrivateMode = false)
+
+ val expected = TabStripState(tabs = emptyList())
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN private mode is off THEN tabs strip state tabs should include only non private tabs`() {
+ val browserState = BrowserState(
+ tabs = listOf(
+ createTab(
+ url = "https://example.com",
+ title = "Example 1",
+ private = false,
+ id = "1",
+ ),
+ createTab(
+ url = "https://example2.com",
+ title = "Example 2",
+ private = true,
+ id = "2",
+ ),
+ createTab(
+ url = "https://example3.com",
+ title = "Example 3",
+ private = false,
+ id = "3",
+ ),
+ ),
+ )
+ val actual = browserState.toTabStripState(isSelectDisabled = false, isPrivateMode = false)
+
+ val expected = TabStripState(
+ tabs = listOf(
+ TabStripItem(
+ id = "1",
+ title = "Example 1",
+ url = "https://example.com",
+ isSelected = false,
+ isPrivate = false,
+ ),
+ TabStripItem(
+ id = "3",
+ title = "Example 3",
+ url = "https://example3.com",
+ isSelected = false,
+ isPrivate = false,
+ ),
+ ),
+ )
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN private mode is on THEN tabs strip state tabs should include only private tabs`() {
+ val browserState = BrowserState(
+ tabs = listOf(
+ createTab(
+ url = "https://example.com",
+ title = "Example",
+ private = false,
+ id = "1",
+ ),
+ createTab(
+ url = "https://example2.com",
+ title = "Private Example",
+ private = true,
+ id = "2",
+ ),
+ createTab(
+ url = "https://example3.com",
+ title = "Example 3",
+ private = true,
+ id = "3",
+ ),
+ ),
+ )
+ val actual = browserState.toTabStripState(isSelectDisabled = false, isPrivateMode = true)
+
+ val expected = TabStripState(
+ tabs = listOf(
+ TabStripItem(
+ id = "2",
+ title = "Private Example",
+ url = "https://example2.com",
+ isSelected = false,
+ isPrivate = true,
+ ),
+ TabStripItem(
+ id = "3",
+ title = "Example 3",
+ url = "https://example3.com",
+ isSelected = false,
+ isPrivate = true,
+ ),
+ ),
+ )
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN isSelectDisabled is false THEN tabs strip state tabs should have a selected tab`() {
+ val browserState = BrowserState(
+ tabs = listOf(
+ createTab(
+ url = "https://example.com",
+ title = "Example 1",
+ private = false,
+ id = "1",
+ ),
+ createTab(
+ url = "https://example2.com",
+ title = "Example 2",
+ private = false,
+ id = "2",
+ ),
+ ),
+ selectedTabId = "2",
+ )
+ val actual = browserState.toTabStripState(isSelectDisabled = false, isPrivateMode = false)
+
+ val expected = TabStripState(
+ tabs = listOf(
+ TabStripItem(
+ id = "1",
+ title = "Example 1",
+ url = "https://example.com",
+ isSelected = false,
+ isPrivate = false,
+ ),
+ TabStripItem(
+ id = "2",
+ title = "Example 2",
+ url = "https://example2.com",
+ isSelected = true,
+ isPrivate = false,
+ ),
+ ),
+ )
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN isSelectDisabled is false and selected tab is private THEN tabs strip state tabs should have private tabs including the selected tab`() {
+ val browserState = BrowserState(
+ tabs = listOf(
+ createTab(
+ url = "https://example.com",
+ title = "Example 1",
+ private = false,
+ id = "1",
+ ),
+ createTab(
+ url = "https://example2.com",
+ title = "Example 2",
+ private = true,
+ id = "2",
+ ),
+ createTab(
+ url = "https://example3.com",
+ title = "Example 3",
+ private = true,
+ id = "3",
+ ),
+ ),
+ selectedTabId = "2",
+ )
+ val actual = browserState.toTabStripState(isSelectDisabled = false, isPrivateMode = false)
+
+ val expected = TabStripState(
+ tabs = listOf(
+ TabStripItem(
+ id = "2",
+ title = "Example 2",
+ url = "https://example2.com",
+ isSelected = true,
+ isPrivate = true,
+ ),
+ TabStripItem(
+ id = "3",
+ title = "Example 3",
+ url = "https://example3.com",
+ isSelected = false,
+ isPrivate = true,
+ ),
+ ),
+ )
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN isSelectDisabled is true THEN tabs strip state tabs should not have a selected tab`() {
+ val browserState = BrowserState(
+ tabs = listOf(
+ createTab(
+ url = "https://example.com",
+ title = "Example 1",
+ private = false,
+ id = "1",
+ ),
+ createTab(
+ url = "https://example2.com",
+ title = "Example 2",
+ private = false,
+ id = "2",
+ ),
+ ),
+ selectedTabId = "2",
+ )
+ val actual = browserState.toTabStripState(isSelectDisabled = true, isPrivateMode = false)
+
+ val expected = TabStripState(
+ tabs = listOf(
+ TabStripItem(
+ id = "1",
+ title = "Example 1",
+ url = "https://example.com",
+ isSelected = false,
+ isPrivate = false,
+ ),
+ TabStripItem(
+ id = "2",
+ title = "Example 2",
+ url = "https://example2.com",
+ isSelected = false,
+ isPrivate = false,
+ ),
+ ),
+ )
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN a tab does not have a title THEN tabs strip should display the url`() {
+ val browserState = BrowserState(
+ tabs = listOf(
+ createTab(
+ url = "https://example.com",
+ title = "Example 1",
+ private = false,
+ id = "1",
+ ),
+ createTab(
+ url = "https://example2.com",
+ title = "",
+ private = false,
+ id = "2",
+ ),
+ ),
+ selectedTabId = "2",
+ )
+ val actual = browserState.toTabStripState(isSelectDisabled = false, isPrivateMode = false)
+
+ val expected = TabStripState(
+ tabs = listOf(
+ TabStripItem(
+ id = "1",
+ title = "Example 1",
+ url = "https://example.com",
+ isSelected = false,
+ isPrivate = false,
+ ),
+ TabStripItem(
+ id = "2",
+ title = "https://example2.com",
+ url = "https://example2.com",
+ isSelected = true,
+ isPrivate = false,
+ ),
+ ),
+ )
+
+ assertEquals(expected, actual)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/CollectionCreationBottomBarViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/CollectionCreationBottomBarViewTest.kt
new file mode 100644
index 0000000000..c5694accbc
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/CollectionCreationBottomBarViewTest.kt
@@ -0,0 +1,167 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.collections
+
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.ImageButton
+import android.widget.TextView
+import androidx.core.view.isVisible
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.feature.tab.collections.TabCollection
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class CollectionCreationBottomBarViewTest {
+
+ private lateinit var bottomBarView: CollectionCreationBottomBarView
+ private lateinit var interactor: CollectionCreationInteractor
+ private lateinit var layout: ViewGroup
+ private lateinit var iconButton: ImageButton
+ private lateinit var textView: TextView
+ private lateinit var saveButton: Button
+
+ @Before
+ fun setup() {
+ interactor = mockk(relaxed = true)
+ layout = spyk()
+ iconButton = ImageButton(testContext)
+ textView = TextView(testContext)
+ saveButton = Button(testContext)
+
+ bottomBarView = CollectionCreationBottomBarView(
+ interactor,
+ layout,
+ iconButton,
+ textView,
+ saveButton,
+ )
+ }
+
+ @Test
+ fun testIconButtonUpdateForSelectTabs() {
+ bottomBarView.update(SaveCollectionStep.SelectTabs, CollectionCreationState())
+
+ verify { layout.setOnClickListener(null) }
+ verify { layout.isClickable = false }
+
+ assertEquals("Close", iconButton.contentDescription)
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_YES, iconButton.importantForAccessibility)
+
+ iconButton.performClick()
+ verify { interactor.close() }
+ }
+
+ @Test
+ fun testIconButtonUpdateForSelectCollection() {
+ bottomBarView.update(SaveCollectionStep.SelectCollection, CollectionCreationState())
+
+ assertEquals(null, iconButton.contentDescription)
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO, iconButton.importantForAccessibility)
+
+ layout.performClick()
+ verify { interactor.addNewCollection() }
+
+ iconButton.performClick()
+ verify { interactor.addNewCollection() }
+ }
+
+ @Test
+ fun testTextViewUpdateForSelectTabs() {
+ bottomBarView.update(
+ SaveCollectionStep.SelectTabs,
+ CollectionCreationState(
+ selectedTabs = emptySet(),
+ ),
+ )
+ assertEquals("Select tabs to save", textView.text)
+
+ bottomBarView.update(
+ SaveCollectionStep.SelectTabs,
+ CollectionCreationState(
+ selectedTabs = setOf(mockk()),
+ ),
+ )
+ assertEquals("1 tab selected", textView.text)
+
+ bottomBarView.update(
+ SaveCollectionStep.SelectTabs,
+ CollectionCreationState(
+ selectedTabs = setOf(mockk(), mockk()),
+ ),
+ )
+ assertEquals("2 tabs selected", textView.text)
+ }
+
+ @Test
+ fun testTextViewUpdateForSelectCollection() {
+ bottomBarView.update(SaveCollectionStep.SelectCollection, CollectionCreationState())
+
+ assertEquals("Add new collection", textView.text)
+ }
+
+ @Test
+ fun testSaveButtonUpdateForSelectTabs() {
+ val collection = mockk<TabCollection>()
+ val tabs = setOf<Tab>(mockk(), mockk())
+
+ bottomBarView.update(
+ SaveCollectionStep.SelectTabs,
+ CollectionCreationState(
+ selectedTabCollection = null,
+ selectedTabs = emptySet(),
+ ),
+ )
+ assertFalse(saveButton.isVisible)
+
+ bottomBarView.update(
+ SaveCollectionStep.SelectTabs,
+ CollectionCreationState(
+ selectedTabCollection = collection,
+ selectedTabs = emptySet(),
+ ),
+ )
+ assertFalse(saveButton.isVisible)
+
+ bottomBarView.update(
+ SaveCollectionStep.SelectTabs,
+ CollectionCreationState(
+ selectedTabCollection = null,
+ selectedTabs = tabs,
+ ),
+ )
+ assertTrue(saveButton.isVisible)
+ saveButton.performClick()
+ verify { interactor.saveTabsToCollection(tabs.toList()) }
+
+ bottomBarView.update(
+ SaveCollectionStep.SelectTabs,
+ CollectionCreationState(
+ selectedTabCollection = collection,
+ selectedTabs = tabs,
+ ),
+ )
+ assertTrue(saveButton.isVisible)
+ saveButton.performClick()
+ verify { interactor.selectCollection(collection, tabs.toList()) }
+ }
+
+ @Test
+ fun testSaveButtonUpdateForSelectCollection() {
+ bottomBarView.update(SaveCollectionStep.SelectCollection, CollectionCreationState())
+
+ assertFalse(saveButton.isVisible)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/CollectionCreationFragmentTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/CollectionCreationFragmentTest.kt
new file mode 100644
index 0000000000..897bcf93fa
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/CollectionCreationFragmentTest.kt
@@ -0,0 +1,70 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.collections
+
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.CompletableDeferred
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.lib.publicsuffixlist.PublicSuffixList
+import mozilla.components.support.test.robolectric.createAddedTestFragment
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.components.TabCollectionStorage
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.helpers.perf.TestStrictModeManager
+
+private const val URL_MOZILLA = "www.mozilla.org"
+private const val SESSION_ID_MOZILLA = "0"
+private const val URL_BCC = "www.bcc.co.uk"
+private const val SESSION_ID_BCC = "1"
+
+@RunWith(FenixRobolectricTestRunner::class)
+class CollectionCreationFragmentTest {
+
+ private val publicSuffixList = mockk<PublicSuffixList>(relaxed = true)
+
+ private val sessionMozilla = createTab(URL_MOZILLA, id = SESSION_ID_MOZILLA)
+ private val sessionBcc = createTab(URL_BCC, id = SESSION_ID_BCC)
+ private val state = BrowserState(
+ tabs = listOf(sessionMozilla, sessionBcc),
+ )
+
+ @Before
+ fun before() {
+ MockKAnnotations.init(this)
+ every { publicSuffixList.stripPublicSuffix(URL_MOZILLA) } returns CompletableDeferred(URL_MOZILLA)
+ every { publicSuffixList.stripPublicSuffix(URL_BCC) } returns CompletableDeferred(URL_BCC)
+ every { testContext.components.publicSuffixList } returns publicSuffixList
+ }
+
+ @Test
+ fun `creation dialog shows and can be dismissed`() {
+ every { testContext.components.analytics } returns mockk(relaxed = true)
+ every { testContext.components.core.store } returns BrowserStore(state)
+ every { testContext.components.core.tabCollectionStorage } returns TabCollectionStorage(
+ testContext,
+ TestStrictModeManager(),
+ )
+ val fragment = createAddedTestFragment {
+ CollectionCreationFragment().apply {
+ arguments = CollectionCreationFragmentArgs(
+ saveCollectionStep = SaveCollectionStep.SelectTabs,
+ ).toBundle()
+ }
+ }
+
+ assertNotNull(fragment.dialog)
+ assertTrue(fragment.requireDialog().isShowing)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/CollectionCreationStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/CollectionCreationStoreTest.kt
new file mode 100644
index 0000000000..7031f85e7d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/CollectionCreationStoreTest.kt
@@ -0,0 +1,235 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.collections
+
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import kotlinx.coroutines.CompletableDeferred
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.ReaderState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.lib.publicsuffixlist.PublicSuffixList
+import mozilla.components.support.test.ext.joinBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.components.TabCollectionStorage
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+private const val URL_MOZILLA = "www.mozilla.org"
+private const val SESSION_ID_MOZILLA = "0"
+private const val URL_BCC = "www.bcc.co.uk"
+private const val SESSION_ID_BCC = "1"
+
+private const val SESSION_ID_BAD_1 = "not a real session id"
+private const val SESSION_ID_BAD_2 = "definitely not a real session id"
+
+@RunWith(FenixRobolectricTestRunner::class)
+class CollectionCreationStoreTest {
+
+ @MockK private lateinit var tabCollectionStorage: TabCollectionStorage
+
+ @MockK(relaxed = true)
+ private lateinit var publicSuffixList: PublicSuffixList
+
+ private val sessionMozilla = createTab(URL_MOZILLA, id = SESSION_ID_MOZILLA)
+ private val sessionBcc = createTab(URL_BCC, id = SESSION_ID_BCC)
+ private val state = BrowserState(
+ tabs = listOf(sessionMozilla, sessionBcc),
+ )
+
+ @Before
+ fun before() {
+ MockKAnnotations.init(this)
+ every { tabCollectionStorage.cachedTabCollections } returns emptyList()
+ every { publicSuffixList.stripPublicSuffix(URL_MOZILLA) } returns CompletableDeferred(URL_MOZILLA)
+ every { publicSuffixList.stripPublicSuffix(URL_BCC) } returns CompletableDeferred(URL_BCC)
+ }
+
+ @Test
+ fun `select and deselect all tabs`() {
+ val tabs = listOf<Tab>(mockk(), mockk())
+ val store = CollectionCreationStore(
+ CollectionCreationState(
+ tabs = tabs,
+ selectedTabs = emptySet(),
+ ),
+ )
+
+ store.dispatch(CollectionCreationAction.AddAllTabs).joinBlocking()
+ assertEquals(tabs.toSet(), store.state.selectedTabs)
+
+ store.dispatch(CollectionCreationAction.RemoveAllTabs).joinBlocking()
+ assertEquals(emptySet<Tab>(), store.state.selectedTabs)
+ }
+
+ @Test
+ fun `select and deselect individual tabs`() {
+ val tab1 = mockk<Tab>()
+ val tab2 = mockk<Tab>()
+ val tab3 = mockk<Tab>()
+ val store = CollectionCreationStore(
+ CollectionCreationState(
+ tabs = listOf(tab1, tab2),
+ selectedTabs = setOf(tab2),
+ ),
+ )
+
+ store.dispatch(CollectionCreationAction.TabAdded(tab2)).joinBlocking()
+ assertEquals(setOf(tab2), store.state.selectedTabs)
+
+ store.dispatch(CollectionCreationAction.TabAdded(tab1)).joinBlocking()
+ assertEquals(setOf(tab1, tab2), store.state.selectedTabs)
+
+ store.dispatch(CollectionCreationAction.TabAdded(tab3)).joinBlocking()
+ assertEquals(setOf(tab1, tab2, tab3), store.state.selectedTabs)
+
+ store.dispatch(CollectionCreationAction.TabRemoved(tab2)).joinBlocking()
+ assertEquals(setOf(tab1, tab3), store.state.selectedTabs)
+ }
+
+ @Test
+ fun `change the current step`() {
+ val store = CollectionCreationStore(
+ CollectionCreationState(
+ saveCollectionStep = SaveCollectionStep.SelectTabs,
+ defaultCollectionNumber = 1,
+ ),
+ )
+
+ store.dispatch(
+ CollectionCreationAction.StepChanged(
+ saveCollectionStep = SaveCollectionStep.RenameCollection,
+ defaultCollectionNumber = 3,
+ ),
+ ).joinBlocking()
+ assertEquals(SaveCollectionStep.RenameCollection, store.state.saveCollectionStep)
+ assertEquals(3, store.state.defaultCollectionNumber)
+ }
+
+ @Test
+ fun `GIVEN no selected tab ids WHEN create initial state THEN only tab will be selected`() {
+ val result = createInitialCollectionCreationState(
+ browserState = state,
+ tabCollectionStorage = tabCollectionStorage,
+ publicSuffixList = publicSuffixList,
+ saveCollectionStep = SaveCollectionStep.NameCollection,
+ tabIds = arrayOf(SESSION_ID_MOZILLA),
+ selectedTabIds = null,
+ selectedTabCollectionId = 0,
+ )
+
+ assertEquals(SaveCollectionStep.NameCollection, result.saveCollectionStep)
+ assertEquals(1, result.tabs.size)
+ assertEquals(SESSION_ID_MOZILLA, result.tabs[0].sessionId)
+ assertEquals(1, result.selectedTabs.size)
+ assertEquals(SESSION_ID_MOZILLA, result.selectedTabs.first().sessionId)
+ }
+
+ @Test
+ fun `GIVEN no selected tab ids WHEN create initial state with many tabs THEN nothing will be selected`() {
+ val result = createInitialCollectionCreationState(
+ browserState = state,
+ tabCollectionStorage = tabCollectionStorage,
+ publicSuffixList = publicSuffixList,
+ saveCollectionStep = SaveCollectionStep.NameCollection,
+ tabIds = arrayOf(SESSION_ID_MOZILLA, SESSION_ID_BCC),
+ selectedTabIds = null,
+ selectedTabCollectionId = 0,
+ )
+
+ assertEquals(SaveCollectionStep.NameCollection, result.saveCollectionStep)
+ assertEquals(2, result.tabs.size)
+ assertEquals(SESSION_ID_MOZILLA, result.tabs[0].sessionId)
+ assertEquals(SESSION_ID_BCC, result.tabs[1].sessionId)
+ assertEquals(0, result.selectedTabs.size)
+ }
+
+ @Test
+ fun `GIVEN selected tab ids WHEN create initial state THEN select tabs`() {
+ val result = createInitialCollectionCreationState(
+ browserState = state,
+ tabCollectionStorage = tabCollectionStorage,
+ publicSuffixList = publicSuffixList,
+ saveCollectionStep = SaveCollectionStep.RenameCollection,
+ tabIds = arrayOf(SESSION_ID_MOZILLA, SESSION_ID_BCC),
+ selectedTabIds = arrayOf(SESSION_ID_BCC),
+ selectedTabCollectionId = 0,
+ )
+
+ assertEquals(SaveCollectionStep.RenameCollection, result.saveCollectionStep)
+ assertEquals(2, result.tabs.size)
+ assertEquals(SESSION_ID_MOZILLA, result.tabs[0].sessionId)
+ assertEquals(SESSION_ID_BCC, result.tabs[1].sessionId)
+ assertEquals(1, result.selectedTabs.size)
+ assertEquals(SESSION_ID_BCC, result.selectedTabs.first().sessionId)
+ }
+
+ @Test
+ fun `GIVEN tabs are present in state WHEN getTabs is called THEN tabs will be returned`() {
+ val tabs = state.getTabs(arrayOf(SESSION_ID_MOZILLA, SESSION_ID_BCC), publicSuffixList)
+
+ val hosts = tabs.map { it.hostname }
+
+ assertEquals(URL_MOZILLA, hosts[0])
+ assertEquals(URL_BCC, hosts[1])
+ }
+
+ @Test
+ fun `GIVEN some tabs are present in state WHEN getTabs is called THEN only valid tabs will be returned`() {
+ val tabs = state.getTabs(arrayOf(SESSION_ID_MOZILLA, SESSION_ID_BAD_1), publicSuffixList)
+
+ val hosts = tabs.map { it.hostname }
+
+ assertEquals(URL_MOZILLA, hosts[0])
+ assertEquals(1, hosts.size)
+ }
+
+ @Test
+ fun `GIVEN tabs are not present in state WHEN getTabs is called THEN an empty list will be returned`() {
+ val tabs = state.getTabs(arrayOf(SESSION_ID_BAD_1, SESSION_ID_BAD_2), publicSuffixList)
+
+ assertEquals(emptyList<Tab>(), tabs)
+ }
+
+ @Test
+ fun `WHEN getTabs is called will null tabIds THEN an empty list will be returned`() {
+ val tabs = state.getTabs(null, publicSuffixList)
+
+ assertEquals(emptyList<Tab>(), tabs)
+ }
+
+ @Test
+ fun `toTab uses active reader URL`() {
+ val tabWithoutReaderState = createTab(url = "https://example.com", id = "1")
+
+ val tabWithInactiveReaderState = createTab(
+ url = "https://blog.mozilla.org",
+ id = "2",
+ readerState = ReaderState(active = false, activeUrl = null),
+ )
+
+ val tabWithActiveReaderState = createTab(
+ url = "moz-extension://123",
+ id = "3",
+ readerState = ReaderState(active = true, activeUrl = "https://blog.mozilla.org/123"),
+ )
+
+ val state = BrowserState(
+ tabs = listOf(tabWithoutReaderState, tabWithInactiveReaderState, tabWithActiveReaderState),
+ )
+ val tabs = state.getTabs(
+ arrayOf(tabWithoutReaderState.id, tabWithInactiveReaderState.id, tabWithActiveReaderState.id),
+ publicSuffixList,
+ )
+
+ assertEquals(tabWithoutReaderState.content.url, tabs[0].url)
+ assertEquals(tabWithInactiveReaderState.content.url, tabs[1].url)
+ assertEquals("https://blog.mozilla.org/123", tabs[2].url)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/CollectionCreationTabListAdapterTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/CollectionCreationTabListAdapterTest.kt
new file mode 100644
index 0000000000..729880b33c
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/CollectionCreationTabListAdapterTest.kt
@@ -0,0 +1,125 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.collections
+
+import android.widget.FrameLayout
+import androidx.core.view.isInvisible
+import androidx.recyclerview.widget.RecyclerView
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.browser.icons.BrowserIcons
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.databinding.CollectionTabListRowBinding
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class CollectionCreationTabListAdapterTest {
+
+ private lateinit var interactor: CollectionCreationInteractor
+ private lateinit var adapter: CollectionCreationTabListAdapter
+ private lateinit var mozillaTab: Tab
+
+ @Before
+ fun setup() {
+ interactor = mockk()
+ adapter = CollectionCreationTabListAdapter(interactor)
+ mozillaTab = mockk {
+ every { sessionId } returns "abc"
+ every { title } returns "Mozilla"
+ every { hostname } returns "mozilla.org"
+ every { url } returns "https://mozilla.org"
+ }
+
+ every { interactor.selectCollection(any(), any()) } just Runs
+ }
+
+ @Test
+ fun `getItemCount should return the number of tab collections`() {
+ val tab = mockk<Tab>()
+
+ assertEquals(0, adapter.itemCount)
+
+ adapter.updateData(
+ tabs = listOf(tab),
+ selectedTabs = emptySet(),
+ )
+ assertEquals(1, adapter.itemCount)
+ }
+
+ @Test
+ fun `creates and binds viewholder`() {
+ every { testContext.components.core.icons } returns BrowserIcons(testContext, mockk(relaxed = true))
+ adapter.updateData(
+ tabs = listOf(mozillaTab),
+ selectedTabs = emptySet(),
+ hideCheckboxes = false,
+ )
+
+ val holder = adapter.createViewHolder(FrameLayout(testContext), 0)
+ adapter.bindViewHolder(holder, 0)
+ val binding = CollectionTabListRowBinding.bind(holder.itemView)
+
+ assertEquals("Mozilla", binding.tabTitle.text)
+ assertEquals("mozilla.org", binding.hostname.text)
+ assertFalse(binding.tabSelectedCheckbox.isInvisible)
+ assertTrue(holder.itemView.isClickable)
+
+ every { interactor.addTabToSelection(mozillaTab) } just Runs
+ every { interactor.removeTabFromSelection(mozillaTab) } just Runs
+ assertFalse(binding.tabSelectedCheckbox.isChecked)
+
+ binding.tabSelectedCheckbox.isChecked = true
+ verify { interactor.addTabToSelection(mozillaTab) }
+
+ binding.tabSelectedCheckbox.isChecked = false
+ verify { interactor.removeTabFromSelection(mozillaTab) }
+ }
+
+ @Test
+ fun `creates and binds viewholder for selected tab`() {
+ every { testContext.components.core.icons } returns BrowserIcons(testContext, mockk(relaxed = true))
+ every { interactor.addTabToSelection(mozillaTab) } just Runs
+
+ adapter.updateData(
+ tabs = listOf(mozillaTab),
+ selectedTabs = setOf(mozillaTab),
+ hideCheckboxes = true,
+ )
+
+ val holder = adapter.createViewHolder(FrameLayout(testContext), 0)
+ adapter.bindViewHolder(holder, 0)
+ val binding = CollectionTabListRowBinding.bind(holder.itemView)
+
+ assertEquals("Mozilla", binding.tabTitle.text)
+ assertEquals("mozilla.org", binding.hostname.text)
+ assertTrue(binding.tabSelectedCheckbox.isInvisible)
+ assertFalse(holder.itemView.isClickable)
+ }
+
+ @Test
+ fun `updateData inserts item`() {
+ val tab = mockk<Tab> {
+ every { sessionId } returns "abc"
+ }
+ val observer = mockk<RecyclerView.AdapterDataObserver>(relaxed = true)
+ adapter.registerAdapterDataObserver(observer)
+ adapter.updateData(
+ tabs = listOf(tab),
+ selectedTabs = emptySet(),
+ )
+
+ verify { observer.onItemRangeInserted(0, 1) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/CollectionsListAdapterTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/CollectionsListAdapterTest.kt
new file mode 100644
index 0000000000..1073ecef7f
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/CollectionsListAdapterTest.kt
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.collections
+
+import android.widget.FrameLayout
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class CollectionsListAdapterTest {
+ private val collectionList: Array<String> =
+ arrayOf(
+ "Add new collection",
+ "Collection 1",
+ "Collection 2",
+ )
+ private val onNewCollectionClicked: () -> Unit = mockk(relaxed = true)
+
+ @Test
+ fun `getItemCount should return the correct list size`() {
+ val adapter = CollectionsListAdapter(collectionList, onNewCollectionClicked)
+
+ assertEquals(3, adapter.itemCount)
+ }
+
+ @Test
+ fun `getSelectedCollection should account for add new collection when returning right item`() {
+ val adapter = CollectionsListAdapter(collectionList, onNewCollectionClicked)
+
+ // first collection by default
+ assertEquals(1, adapter.checkedPosition)
+ assertEquals(0, adapter.getSelectedCollection())
+
+ adapter.checkedPosition = 3
+ assertEquals(2, adapter.getSelectedCollection())
+ }
+
+ @Test
+ fun `creates and binds viewholder`() {
+ val adapter = CollectionsListAdapter(collectionList, onNewCollectionClicked)
+
+ val holder1 = adapter.createViewHolder(FrameLayout(testContext), 0)
+ val holder2 = adapter.createViewHolder(FrameLayout(testContext), 0)
+ val holder3 = adapter.createViewHolder(FrameLayout(testContext), 0)
+
+ adapter.bindViewHolder(holder1, 0)
+ adapter.bindViewHolder(holder2, 1)
+ adapter.bindViewHolder(holder3, 2)
+
+ assertEquals("Add new collection", holder1.textView.text)
+ holder1.textView.callOnClick()
+ verify {
+ onNewCollectionClicked()
+ }
+
+ assertEquals(true, holder2.textView.isChecked)
+ assertEquals("Collection 1", holder2.textView.text)
+ holder2.textView.callOnClick()
+ assertEquals(true, holder2.textView.isChecked)
+
+ assertEquals(false, holder3.textView.isChecked)
+ assertEquals("Collection 2", holder3.textView.text)
+ holder3.textView.callOnClick()
+ adapter.bindViewHolder(holder3, 2)
+ assertEquals(true, holder3.textView.isChecked)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/DefaultCollectionCreationControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/DefaultCollectionCreationControllerTest.kt
new file mode 100644
index 0000000000..305aaffd0b
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/DefaultCollectionCreationControllerTest.kt
@@ -0,0 +1,295 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.collections
+
+import io.mockk.MockKAnnotations
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.test.advanceUntilIdle
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.feature.tab.collections.TabCollection
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.Collections
+import org.mozilla.fenix.components.TabCollectionStorage
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class) // For gleanTestRule
+class DefaultCollectionCreationControllerTest {
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val scope = coroutinesTestRule.scope
+
+ private lateinit var state: CollectionCreationState
+ private lateinit var controller: DefaultCollectionCreationController
+ private var dismissed = false
+
+ @MockK(relaxed = true)
+ private lateinit var store: CollectionCreationStore
+
+ @MockK(relaxUnitFun = true)
+ private lateinit var tabCollectionStorage: TabCollectionStorage
+ private lateinit var browserStore: BrowserStore
+
+ @Before
+ fun before() {
+ MockKAnnotations.init(this)
+
+ state = CollectionCreationState(
+ tabCollections = emptyList(),
+ tabs = emptyList(),
+ )
+ every { store.state } answers { state }
+
+ browserStore = BrowserStore()
+
+ dismissed = false
+ controller = DefaultCollectionCreationController(
+ store,
+ browserStore,
+ dismiss = {
+ dismissed = true
+ },
+ tabCollectionStorage,
+ scope,
+ )
+ }
+
+ @Test
+ fun `GIVEN tab list WHEN saveCollectionName is called THEN collection should be created`() {
+ val tab1 = createTab("https://www.mozilla.org", id = "session-1")
+ val tab2 = createTab("https://www.mozilla.org", id = "session-2")
+
+ browserStore.dispatch(
+ TabListAction.AddMultipleTabsAction(listOf(tab1, tab2)),
+ ).joinBlocking()
+
+ coEvery { tabCollectionStorage.addTabsToCollection(any(), any()) } returns 1L
+ coEvery { tabCollectionStorage.createCollection(any(), any()) } returns 1L
+
+ val tabs = listOf(
+ Tab("session-1", "", "", ""),
+ Tab("null-session", "", "", ""),
+ )
+
+ controller.saveCollectionName(tabs, "name")
+
+ assertTrue(dismissed)
+ coVerify { tabCollectionStorage.createCollection("name", listOf(tab1)) }
+
+ assertNotNull(Collections.saved.testGetValue())
+ val recordedEvents = Collections.saved.testGetValue()!!
+ assertEquals(1, recordedEvents.size)
+ val eventExtra = recordedEvents.single().extra
+ assertNotNull(eventExtra)
+ assertTrue(eventExtra!!.containsKey("tabs_open"))
+ assertEquals("2", eventExtra["tabs_open"])
+ assertTrue(eventExtra.containsKey("tabs_selected"))
+ assertEquals("1", eventExtra["tabs_selected"])
+ }
+
+ @Test
+ fun `GIVEN name collection WHEN backPressed is called THEN next step should be dispatched`() {
+ state = state.copy(tabCollections = listOf(mockk()))
+ controller.backPressed(SaveCollectionStep.NameCollection)
+ verify { store.dispatch(CollectionCreationAction.StepChanged(SaveCollectionStep.SelectCollection)) }
+
+ state = state.copy(tabCollections = emptyList(), tabs = listOf(mockk(), mockk()))
+ controller.backPressed(SaveCollectionStep.NameCollection)
+ verify { store.dispatch(CollectionCreationAction.StepChanged(SaveCollectionStep.SelectTabs)) }
+
+ state = state.copy(tabCollections = emptyList(), tabs = listOf(mockk()))
+ controller.backPressed(SaveCollectionStep.NameCollection)
+ assertTrue(dismissed)
+ }
+
+ @Test
+ fun `GIVEN select collection WHEN backPressed is called THEN next step should be dispatched`() {
+ state = state.copy(tabCollections = emptyList(), tabs = listOf(mockk(), mockk()))
+ controller.backPressed(SaveCollectionStep.SelectCollection)
+ verify { store.dispatch(CollectionCreationAction.StepChanged(SaveCollectionStep.SelectTabs)) }
+
+ state = state.copy(tabCollections = emptyList(), tabs = listOf(mockk()))
+ controller.backPressed(SaveCollectionStep.SelectCollection)
+ assertTrue(dismissed)
+ }
+
+ @Test
+ fun `GIVEN last step WHEN backPressed is called THEN dismiss should be called`() {
+ controller.backPressed(SaveCollectionStep.SelectTabs)
+ assertTrue(dismissed)
+
+ controller.backPressed(SaveCollectionStep.RenameCollection)
+ assertTrue(dismissed)
+ }
+
+ @Test
+ fun `GIVEN collection WHEN renameCollection is called THEN collection should be renamed`() = runTestOnMain {
+ val collection = mockk<TabCollection>()
+
+ controller.renameCollection(collection, "name")
+ advanceUntilIdle()
+
+ assertTrue(dismissed)
+
+ assertNotNull(Collections.renamed.testGetValue())
+ val recordedEvents = Collections.renamed.testGetValue()!!
+ assertEquals(1, recordedEvents.size)
+ assertNull(recordedEvents.single().extra)
+
+ coVerify { tabCollectionStorage.renameCollection(collection, "name") }
+ }
+
+ @Test
+ fun `WHEN select all is called THEN add all should be dispatched`() {
+ controller.selectAllTabs()
+ verify { store.dispatch(CollectionCreationAction.AddAllTabs) }
+
+ controller.deselectAllTabs()
+ verify { store.dispatch(CollectionCreationAction.RemoveAllTabs) }
+
+ controller.close()
+ assertTrue(dismissed)
+ }
+
+ @Test
+ fun `WHEN select tab is called THEN add tab should be dispatched`() {
+ val tab = mockk<Tab>()
+
+ controller.addTabToSelection(tab)
+ verify { store.dispatch(CollectionCreationAction.TabAdded(tab)) }
+
+ controller.removeTabFromSelection(tab)
+ verify { store.dispatch(CollectionCreationAction.TabRemoved(tab)) }
+ }
+
+ @Test
+ fun `WHEN selectCollection is called THEN add tabs should be added to collection`() {
+ val tab1 = createTab("https://www.mozilla.org", id = "session-1")
+ val tab2 = createTab("https://www.mozilla.org", id = "session-2")
+ browserStore.dispatch(
+ TabListAction.AddMultipleTabsAction(listOf(tab1, tab2)),
+ ).joinBlocking()
+
+ val tabs = listOf(
+ Tab("session-1", "", "", ""),
+ )
+ val collection = mockk<TabCollection>()
+ coEvery { tabCollectionStorage.addTabsToCollection(any(), any()) } returns 1L
+ coEvery { tabCollectionStorage.createCollection(any(), any()) } returns 1L
+
+ controller.selectCollection(collection, tabs)
+
+ assertTrue(dismissed)
+ coVerify { tabCollectionStorage.addTabsToCollection(collection, listOf(tab1)) }
+
+ assertNotNull(Collections.tabsAdded.testGetValue())
+ val recordedEvents = Collections.tabsAdded.testGetValue()!!
+ assertEquals(1, recordedEvents.size)
+ val eventExtra = recordedEvents.single().extra
+ assertNotNull(eventExtra)
+ assertTrue(eventExtra!!.containsKey("tabs_open"))
+ assertEquals("2", eventExtra["tabs_open"])
+ assertTrue(eventExtra.containsKey("tabs_selected"))
+ assertEquals("1", eventExtra["tabs_selected"])
+ }
+
+ @Test
+ fun `GIVEN previous step was SelectTabs or RenameCollection WHEN stepBack is called THEN null should be returned`() {
+ assertNull(controller.stepBack(SaveCollectionStep.SelectTabs))
+ assertNull(controller.stepBack(SaveCollectionStep.RenameCollection))
+ }
+
+ @Test
+ fun `GIVEN previous step was SelectCollection AND more than one tab is open WHEN stepBack is called THEN SelectTabs should be returned`() {
+ state = state.copy(tabs = listOf(mockk(), mockk()))
+
+ assertEquals(SaveCollectionStep.SelectTabs, controller.stepBack(SaveCollectionStep.SelectCollection))
+ }
+
+ @Test
+ fun `GIVEN previous step was SelectCollection AND one or fewer tabs are open WHEN stepbback is called THEN null should be returned`() {
+ state = state.copy(tabs = listOf(mockk()))
+ assertNull(controller.stepBack(SaveCollectionStep.SelectCollection))
+
+ state = state.copy(tabs = emptyList())
+ assertNull(controller.stepBack(SaveCollectionStep.SelectCollection))
+ }
+
+ @Test
+ fun `GIVEN previous step was NameCollection AND tabCollections is empty AND more than one tab is open WHEN stepBack is called THEN SelectTabs should be returned`() {
+ state = state.copy(tabCollections = emptyList(), tabs = listOf(mockk(), mockk()))
+
+ assertEquals(SaveCollectionStep.SelectTabs, controller.stepBack(SaveCollectionStep.NameCollection))
+ }
+
+ @Test
+ fun `GIVEN previous step was NameCollection AND tabCollections is empty AND one or fewer tabs are open WHEN stepBack is called THEN null should be returned`() {
+ state = state.copy(tabCollections = emptyList(), tabs = listOf(mockk()))
+ assertNull(controller.stepBack(SaveCollectionStep.NameCollection))
+
+ state = state.copy(tabCollections = emptyList(), tabs = emptyList())
+ assertNull(controller.stepBack(SaveCollectionStep.NameCollection))
+ }
+
+ @Test
+ fun `GIVEN previous step was NameCollection AND tabCollections is not empty WHEN stepBack is called THEN SelectCollection should be returned`() {
+ state = state.copy(tabCollections = listOf(mockk()))
+ assertEquals(SaveCollectionStep.SelectCollection, controller.stepBack(SaveCollectionStep.NameCollection))
+ }
+
+ @Test
+ fun `WHEN adding a new collection THEN dispatch NameCollection step changed`() {
+ controller.addNewCollection()
+
+ verify { store.dispatch(CollectionCreationAction.StepChanged(SaveCollectionStep.NameCollection, 1)) }
+ }
+
+ @Test
+ fun `GIVEN empty list of collections WHEN saving tabs to collection THEN dispatch NameCollection step changed`() {
+ controller.saveTabsToCollection(ArrayList())
+
+ verify { store.dispatch(CollectionCreationAction.StepChanged(SaveCollectionStep.NameCollection, 1)) }
+ }
+
+ @Test
+ fun `GIVEN list of collections WHEN saving tabs to collection THEN dispatch NameCollection step changed`() {
+ state = state.copy(
+ tabCollections = listOf(
+ mockk {
+ every { title } returns "Collection 1"
+ },
+ mockk {
+ every { title } returns "Random Collection"
+ },
+ ),
+ )
+
+ controller.saveTabsToCollection(ArrayList())
+
+ verify { store.dispatch(CollectionCreationAction.StepChanged(SaveCollectionStep.SelectCollection, 2)) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/SaveCollectionListAdapterTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/SaveCollectionListAdapterTest.kt
new file mode 100644
index 0000000000..2e2b11302d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/SaveCollectionListAdapterTest.kt
@@ -0,0 +1,84 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.collections
+
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.feature.tab.collections.TabCollection
+import mozilla.components.lib.publicsuffixlist.PublicSuffixList
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.databinding.CollectionsListItemBinding
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class SaveCollectionListAdapterTest {
+
+ private lateinit var parent: ViewGroup
+ private lateinit var interactor: CollectionCreationInteractor
+ private lateinit var adapter: SaveCollectionListAdapter
+
+ @Before
+ fun setup() {
+ parent = FrameLayout(testContext)
+ interactor = mockk()
+ adapter = SaveCollectionListAdapter(interactor)
+
+ every { interactor.selectCollection(any(), any()) } just Runs
+ }
+
+ @Test
+ fun `getItemCount should return the number of tab collections`() {
+ val collection = mockk<TabCollection>()
+
+ assertEquals(0, adapter.itemCount)
+
+ adapter.updateData(
+ tabCollections = listOf(collection),
+ selectedTabs = emptySet(),
+ )
+ assertEquals(1, adapter.itemCount)
+ }
+
+ @Test
+ fun `creates and binds viewholder`() {
+ every { testContext.components.publicSuffixList } returns PublicSuffixList(testContext)
+ val collection = mockk<TabCollection> {
+ every { id } returns 0L
+ every { title } returns "Collection"
+ every { tabs } returns listOf(
+ mockk {
+ every { url } returns "https://mozilla.org"
+ },
+ mockk {
+ every { url } returns "https://firefox.com"
+ },
+ )
+ }
+ adapter.updateData(
+ tabCollections = listOf(collection),
+ selectedTabs = emptySet(),
+ )
+
+ val holder = adapter.createViewHolder(parent, 0)
+ adapter.bindViewHolder(holder, 0)
+ val binding = CollectionsListItemBinding.bind(holder.itemView)
+
+ assertEquals("Collection", binding.collectionItem.text)
+ assertEquals("mozilla.org, firefox.com", binding.collectionDescription.text)
+
+ holder.itemView.performClick()
+ verify { interactor.selectCollection(collection, emptyList()) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/TabDiffUtilTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/TabDiffUtilTest.kt
new file mode 100644
index 0000000000..53f0674f1c
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/collections/TabDiffUtilTest.kt
@@ -0,0 +1,147 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.collections
+
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class TabDiffUtilTest {
+
+ @Test
+ fun `list size is returned`() {
+ val diffUtil = TabDiffUtil(
+ old = listOf(mockk(), mockk()),
+ new = listOf(mockk()),
+ oldSelected = emptySet(),
+ newSelected = emptySet(),
+ oldHideCheckboxes = false,
+ newHideCheckboxes = false,
+ )
+
+ assertEquals(2, diffUtil.oldListSize)
+ assertEquals(1, diffUtil.newListSize)
+ }
+
+ @Test
+ fun `single lists are the same`() {
+ val tab = mockk<Tab> {
+ every { sessionId } returns "abc"
+ }
+ val diffUtil = TabDiffUtil(
+ old = listOf(tab),
+ new = listOf(tab),
+ oldSelected = emptySet(),
+ newSelected = emptySet(),
+ oldHideCheckboxes = false,
+ newHideCheckboxes = false,
+ )
+
+ assertTrue(diffUtil.areItemsTheSame(0, 0))
+ assertTrue(diffUtil.areContentsTheSame(0, 0))
+ }
+
+ @Test
+ fun `selection affects contents`() {
+ val tab = mockk<Tab> {
+ every { sessionId } returns "abc"
+ }
+ val diffUtil = TabDiffUtil(
+ old = listOf(tab),
+ new = listOf(tab),
+ oldSelected = emptySet(),
+ newSelected = setOf(tab),
+ oldHideCheckboxes = false,
+ newHideCheckboxes = false,
+ )
+
+ assertTrue(diffUtil.areItemsTheSame(0, 0))
+ assertFalse(diffUtil.areContentsTheSame(0, 0))
+ }
+
+ @Test
+ fun `hide checkboxes affects contents`() {
+ val tab = mockk<Tab> {
+ every { sessionId } returns "abc"
+ }
+ val diffUtil = TabDiffUtil(
+ old = listOf(tab),
+ new = listOf(tab),
+ oldSelected = setOf(tab),
+ newSelected = setOf(tab),
+ oldHideCheckboxes = false,
+ newHideCheckboxes = true,
+ )
+
+ assertTrue(diffUtil.areItemsTheSame(0, 0))
+ assertFalse(diffUtil.areContentsTheSame(0, 0))
+ }
+
+ @Test
+ fun `change payload covers no change case`() {
+ val tab = mockk<Tab>()
+ val payload = TabDiffUtil(
+ old = listOf(tab),
+ new = listOf(tab),
+ oldSelected = setOf(tab),
+ newSelected = setOf(tab),
+ oldHideCheckboxes = false,
+ newHideCheckboxes = false,
+ ).getChangePayload(0, 0)
+
+ assertEquals(
+ CheckChanged(
+ shouldBeChecked = true,
+ shouldHideCheckBox = false,
+ ),
+ payload,
+ )
+ }
+
+ @Test
+ fun `include shouldBeChecked in change payload`() {
+ val tab = mockk<Tab>()
+ val payload = TabDiffUtil(
+ old = listOf(tab),
+ new = listOf(tab),
+ oldSelected = emptySet(),
+ newSelected = setOf(tab),
+ oldHideCheckboxes = false,
+ newHideCheckboxes = false,
+ ).getChangePayload(0, 0)
+
+ assertEquals(
+ CheckChanged(
+ shouldBeChecked = true,
+ shouldHideCheckBox = false,
+ ),
+ payload,
+ )
+ }
+
+ @Test
+ fun `include shouldBeUnchecked in change payload`() {
+ val tab = mockk<Tab>()
+ val payload = TabDiffUtil(
+ old = listOf(tab),
+ new = listOf(tab),
+ oldSelected = setOf(tab),
+ newSelected = emptySet(),
+ oldHideCheckboxes = false,
+ newHideCheckboxes = true,
+ ).getChangePayload(0, 0)
+
+ assertEquals(
+ CheckChanged(
+ shouldBeChecked = false,
+ shouldHideCheckBox = true,
+ ),
+ payload,
+ )
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/AccountAbnormalitiesTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/AccountAbnormalitiesTest.kt
new file mode 100644
index 0000000000..c37a44dbb5
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/AccountAbnormalitiesTest.kt
@@ -0,0 +1,160 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components
+
+import io.mockk.Called
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import mozilla.components.lib.crash.CrashReporter
+import mozilla.components.service.fxa.manager.FxaAccountManager
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.fail
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.helpers.perf.TestStrictModeManager
+import org.mozilla.fenix.perf.StrictModeManager
+
+@RunWith(FenixRobolectricTestRunner::class)
+class AccountAbnormalitiesTest {
+ @Test
+ fun `account manager must be configured`() {
+ val crashReporter: CrashReporter = mockk()
+
+ // no account present
+ val accountAbnormalities = AccountAbnormalities(
+ testContext,
+ crashReporter,
+ TestStrictModeManager() as StrictModeManager,
+ )
+
+ try {
+ accountAbnormalities.userRequestedLogout()
+ fail()
+ } catch (e: IllegalStateException) {
+ assertEquals("userRequestedLogout before account manager was configured", e.message)
+ }
+
+ // This doesn't throw, see method for details.
+ accountAbnormalities.onAuthenticated(mockk(), mockk())
+
+ try {
+ accountAbnormalities.onLoggedOut()
+ fail()
+ } catch (e: IllegalStateException) {
+ assertEquals("onLoggedOut before account manager was configured", e.message)
+ }
+
+ verify { crashReporter wasNot Called }
+ }
+
+ @Test
+ fun `LogoutWithoutAuth detected`() = runTest {
+ val crashReporter: CrashReporter = mockk(relaxed = true)
+
+ val accountAbnormalities = AccountAbnormalities(
+ testContext,
+ crashReporter,
+ TestStrictModeManager() as StrictModeManager,
+ )
+ accountAbnormalities.onReady(mockk(relaxed = true))
+
+ // Logout action must be preceded by auth.
+ accountAbnormalities.userRequestedLogout()
+ assertCaughtException<AbnormalFxaEvent.LogoutWithoutAuth>(crashReporter)
+ }
+
+ @Test
+ fun `OverlappingFxaLogoutRequest detected`() = runTest {
+ val crashReporter: CrashReporter = mockk(relaxed = true)
+
+ val accountAbnormalities = AccountAbnormalities(
+ testContext,
+ crashReporter,
+ TestStrictModeManager() as StrictModeManager,
+ )
+ accountAbnormalities.onReady(mockk(relaxed = true))
+
+ accountAbnormalities.onAuthenticated(mockk(), mockk())
+ // So far, so good. A regular logout request while being authenticated.
+ accountAbnormalities.userRequestedLogout()
+ verify { crashReporter wasNot Called }
+
+ // We never saw a logout callback after previous logout request, so this is an overlapping request.
+ accountAbnormalities.userRequestedLogout()
+ assertCaughtException<AbnormalFxaEvent.OverlappingFxaLogoutRequest>(crashReporter)
+ }
+
+ @Test
+ fun `callback logout abnormalities detected`() = runTest {
+ val crashReporter: CrashReporter = mockk(relaxed = true)
+
+ val accountAbnormalities = AccountAbnormalities(
+ testContext,
+ crashReporter,
+ TestStrictModeManager() as StrictModeManager,
+ )
+ accountAbnormalities.onReady(mockk(relaxed = true))
+
+ // User didn't request this logout.
+ accountAbnormalities.onLoggedOut()
+ assertCaughtException<AbnormalFxaEvent.UnexpectedFxaLogout>(crashReporter)
+ }
+
+ @Test
+ fun `login happy case + disappearing account detected`() = runTest {
+ val crashReporter: CrashReporter = mockk(relaxed = true)
+ val accountManager: FxaAccountManager = mockk(relaxed = true)
+
+ val accountAbnormalities = AccountAbnormalities(
+ testContext,
+ crashReporter,
+ TestStrictModeManager() as StrictModeManager,
+ )
+ accountAbnormalities.onReady(null)
+
+ accountAbnormalities.onAuthenticated(mockk(), mockk())
+ verify { crashReporter wasNot Called }
+ every { accountManager.authenticatedAccount() } returns null
+
+ // Pretend we restart, and instantiate a new middleware instance.
+ val accountAbnormalities2 = AccountAbnormalities(
+ testContext,
+ crashReporter,
+ TestStrictModeManager() as StrictModeManager,
+ )
+ // mock accountManager doesn't have an account, but we expect it to have one since we
+ // were authenticated before our "restart".
+ accountAbnormalities2.onReady(null)
+
+ assertCaughtException<AbnormalFxaEvent.MissingExpectedAccountAfterStartup>(crashReporter)
+ }
+
+ @Test
+ fun `logout happy case`() = runTest {
+ val crashReporter: CrashReporter = mockk()
+
+ val accountAbnormalities = AccountAbnormalities(
+ testContext,
+ crashReporter,
+ TestStrictModeManager() as StrictModeManager,
+ )
+ accountAbnormalities.onReady(mockk(relaxed = true))
+
+ // We saw an auth event, then user requested a logout.
+ accountAbnormalities.onAuthenticated(mockk(), mockk())
+ accountAbnormalities.userRequestedLogout()
+ verify { crashReporter wasNot Called }
+ }
+
+ private inline fun <reified T : AbnormalFxaEvent> assertCaughtException(crashReporter: CrashReporter) {
+ verify {
+ crashReporter.submitCaughtException(any<T>())
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/AppStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/AppStoreTest.kt
new file mode 100644
index 0000000000..cfd2e28766
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/AppStoreTest.kt
@@ -0,0 +1,542 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components
+
+import android.content.Context
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import mozilla.components.concept.sync.DeviceType
+import mozilla.components.feature.tab.collections.TabCollection
+import mozilla.components.feature.top.sites.TopSite
+import mozilla.components.service.fxa.manager.FxaAccountManager
+import mozilla.components.service.nimbus.messaging.Message
+import mozilla.components.service.nimbus.messaging.MessageData
+import mozilla.components.service.pocket.PocketStory
+import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory
+import mozilla.components.service.pocket.PocketStory.PocketSponsoredStory
+import mozilla.components.service.pocket.PocketStory.PocketSponsoredStoryCaps
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.browser.browsingmode.BrowsingMode
+import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
+import org.mozilla.fenix.components.appstate.AppAction
+import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.UpdateMessageToShow
+import org.mozilla.fenix.components.appstate.AppState
+import org.mozilla.fenix.components.appstate.filterOut
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.getFilteredStories
+import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory
+import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesSelectedCategory
+import org.mozilla.fenix.home.recentbookmarks.RecentBookmark
+import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTab
+import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTabState
+import org.mozilla.fenix.home.recenttabs.RecentTab
+import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
+import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
+import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryHighlight
+import org.mozilla.fenix.messaging.FenixMessageSurfaceId
+import org.mozilla.fenix.onboarding.FenixOnboarding
+
+class AppStoreTest {
+ private lateinit var context: Context
+ private lateinit var accountManager: FxaAccountManager
+ private lateinit var onboarding: FenixOnboarding
+ private lateinit var browsingModeManager: BrowsingModeManager
+ private lateinit var appState: AppState
+ private lateinit var appStore: AppStore
+ private lateinit var recentSyncedTabsList: List<RecentSyncedTab>
+
+ @Before
+ fun setup() {
+ context = mockk(relaxed = true)
+ accountManager = mockk(relaxed = true)
+ onboarding = mockk(relaxed = true)
+ browsingModeManager = mockk(relaxed = true)
+ recentSyncedTabsList = listOf(
+ RecentSyncedTab(
+ deviceDisplayName = "",
+ deviceType = mockk(relaxed = true),
+ title = "",
+ url = "",
+ previewImageUrl = null,
+ ),
+ )
+
+ every { context.components.backgroundServices.accountManager } returns accountManager
+ every { onboarding.userHasBeenOnboarded() } returns true
+ every { browsingModeManager.mode } returns BrowsingMode.Normal
+
+ appState = AppState(
+ collections = emptyList(),
+ expandedCollections = emptySet(),
+ mode = browsingModeManager.mode,
+ topSites = emptyList(),
+ showCollectionPlaceholder = true,
+ recentTabs = emptyList(),
+ recentSyncedTabState = RecentSyncedTabState.Success(recentSyncedTabsList),
+ )
+
+ appStore = AppStore(appState)
+ }
+
+ @Test
+ fun `Test toggling the mode in AppStore`() = runTest {
+ // Verify that the default mode and tab states of the HomeFragment are correct.
+ assertEquals(BrowsingMode.Normal, appStore.state.mode)
+
+ // Change the AppStore to Private mode.
+ appStore.dispatch(AppAction.ModeChange(BrowsingMode.Private)).join()
+ assertEquals(BrowsingMode.Private, appStore.state.mode)
+
+ // Change the AppStore back to Normal mode.
+ appStore.dispatch(AppAction.ModeChange(BrowsingMode.Normal)).join()
+ assertEquals(BrowsingMode.Normal, appStore.state.mode)
+ }
+
+ @Test
+ fun `GIVEN a new value for messageToShow WHEN NimbusMessageChange is called THEN update the current value`() =
+ runTest {
+ assertTrue(appStore.state.messaging.messageToShow.isEmpty())
+
+ val message = Message(
+ "message",
+ MessageData(surface = FenixMessageSurfaceId.HOMESCREEN),
+ "action",
+ mockk(),
+ emptyList(),
+ emptyList(),
+ mockk(),
+ )
+ appStore.dispatch(UpdateMessageToShow(message)).join()
+
+ assertFalse(appStore.state.messaging.messageToShow.isEmpty())
+ }
+
+ @Test
+ fun `Test changing the collections in AppStore`() = runTest {
+ assertEquals(0, appStore.state.collections.size)
+
+ // Add 2 TabCollections to the AppStore.
+ val tabCollections: List<TabCollection> = listOf(mockk(), mockk())
+ appStore.dispatch(AppAction.CollectionsChange(tabCollections)).join()
+
+ assertEquals(tabCollections, appStore.state.collections)
+ }
+
+ @Test
+ fun `Test changing the top sites in AppStore`() = runTest {
+ assertEquals(0, appStore.state.topSites.size)
+
+ // Add 2 TopSites to the AppStore.
+ val topSites: List<TopSite> = listOf(mockk(), mockk())
+ appStore.dispatch(AppAction.TopSitesChange(topSites)).join()
+
+ assertEquals(topSites, appStore.state.topSites)
+ }
+
+ @Test
+ fun `Test changing the recent tabs in AppStore`() = runTest {
+ val group1 = RecentHistoryGroup(title = "title1")
+ val group2 = RecentHistoryGroup(title = "title2")
+ val group3 = RecentHistoryGroup(title = "title3")
+ val highlight = RecentHistoryHighlight(title = group2.title, "")
+ appStore = AppStore(
+ AppState(
+ recentHistory = listOf(group1, group2, group3, highlight),
+ ),
+ )
+ assertEquals(0, appStore.state.recentTabs.size)
+
+ // Add 2 RecentTabs to the AppStore
+ val recentTab1: RecentTab.Tab = mockk()
+ val recentTabs: List<RecentTab> = listOf(recentTab1)
+ appStore.dispatch(AppAction.RecentTabsChange(recentTabs)).join()
+
+ assertEquals(recentTabs, appStore.state.recentTabs)
+ assertEquals(listOf(group1, group2, group3, highlight), appStore.state.recentHistory)
+ }
+
+ @Test
+ fun `GIVEN initial state WHEN recent synced tab state is changed THEN state updated`() = runTest {
+ appStore = AppStore(
+ AppState(
+ recentSyncedTabState = RecentSyncedTabState.None,
+ ),
+ )
+
+ val loading = RecentSyncedTabState.Loading
+ appStore.dispatch(AppAction.RecentSyncedTabStateChange(loading)).join()
+ assertEquals(loading, appStore.state.recentSyncedTabState)
+
+ val recentSyncedTabs = listOf(RecentSyncedTab("device name", DeviceType.DESKTOP, "title", "url", null))
+ val success = RecentSyncedTabState.Success(recentSyncedTabs)
+ appStore.dispatch(AppAction.RecentSyncedTabStateChange(success)).join()
+ assertEquals(success, appStore.state.recentSyncedTabState)
+ assertEquals(recentSyncedTabs, (appStore.state.recentSyncedTabState as RecentSyncedTabState.Success).tabs)
+ }
+
+ @Test
+ fun `Test changing the history metadata in AppStore`() = runTest {
+ assertEquals(0, appStore.state.recentHistory.size)
+
+ val historyMetadata: List<RecentHistoryGroup> = listOf(mockk(), mockk())
+ appStore.dispatch(AppAction.RecentHistoryChange(historyMetadata)).join()
+
+ assertEquals(historyMetadata, appStore.state.recentHistory)
+ }
+
+ @Test
+ fun `Test removing a history highlight from AppStore`() = runTest {
+ val g1 = RecentHistoryGroup(title = "group One")
+ val g2 = RecentHistoryGroup(title = "grup two")
+ val h1 = RecentHistoryHighlight(title = "highlight One", url = "url1")
+ val h2 = RecentHistoryHighlight(title = "highlight two", url = "url2")
+ val recentHistoryState = AppState(
+ recentHistory = listOf(g1, g2, h1, h2),
+ )
+ appStore = AppStore(recentHistoryState)
+
+ appStore.dispatch(AppAction.RemoveRecentHistoryHighlight("invalid")).join()
+ assertEquals(recentHistoryState, appStore.state)
+
+ appStore.dispatch(AppAction.RemoveRecentHistoryHighlight(h1.title)).join()
+ assertEquals(recentHistoryState, appStore.state)
+
+ appStore.dispatch(AppAction.RemoveRecentHistoryHighlight(h1.url)).join()
+ assertEquals(
+ recentHistoryState.copy(recentHistory = listOf(g1, g2, h2)),
+ appStore.state,
+ )
+ }
+
+ @Test
+ fun `Test disbanding search group in AppStore`() = runTest {
+ val g1 = RecentHistoryGroup(title = "test One")
+ val g2 = RecentHistoryGroup(title = "test two")
+ val h1 = RecentHistoryHighlight(title = "highlight One", url = "url1")
+ val h2 = RecentHistoryHighlight(title = "highlight two", url = "url2")
+ val recentHistory: List<RecentlyVisitedItem> = listOf(g1, g2, h1, h2)
+ appStore.dispatch(AppAction.RecentHistoryChange(recentHistory)).join()
+ assertEquals(recentHistory, appStore.state.recentHistory)
+
+ appStore.dispatch(AppAction.DisbandSearchGroupAction("Test one")).join()
+ assertEquals(listOf(g2, h1, h2), appStore.state.recentHistory)
+ }
+
+ @Test
+ fun `Test changing hiding collections placeholder`() = runTest {
+ assertTrue(appStore.state.showCollectionPlaceholder)
+
+ appStore.dispatch(AppAction.RemoveCollectionsPlaceholder).join()
+
+ assertFalse(appStore.state.showCollectionPlaceholder)
+ }
+
+ @Test
+ fun `Test changing the expanded collections in AppStore`() = runTest {
+ val collection: TabCollection = mockk<TabCollection>().apply {
+ every { id } returns 0
+ }
+
+ // Expand the given collection.
+ appStore.dispatch(AppAction.CollectionsChange(listOf(collection))).join()
+ appStore.dispatch(AppAction.CollectionExpanded(collection, true)).join()
+
+ assertTrue(appStore.state.expandedCollections.contains(collection.id))
+ assertEquals(1, appStore.state.expandedCollections.size)
+ }
+
+ @Test
+ fun `Test changing the collections, mode, recent tabs and bookmarks, history metadata, top sites and recent synced tabs in the AppStore`() =
+ runTest {
+ // Verify that the default state of the HomeFragment is correct.
+ assertEquals(0, appStore.state.collections.size)
+ assertEquals(0, appStore.state.topSites.size)
+ assertEquals(0, appStore.state.recentTabs.size)
+ assertEquals(0, appStore.state.recentBookmarks.size)
+ assertEquals(0, appStore.state.recentHistory.size)
+ assertEquals(BrowsingMode.Normal, appStore.state.mode)
+ assertEquals(
+ RecentSyncedTabState.Success(recentSyncedTabsList),
+ appStore.state.recentSyncedTabState,
+ )
+
+ val collections: List<TabCollection> = listOf(mockk())
+ val topSites: List<TopSite> = listOf(mockk(), mockk())
+ val recentTabs: List<RecentTab> = listOf(mockk(), mockk())
+ val recentBookmarks: List<RecentBookmark> = listOf(mockk(), mockk())
+ val group1 = RecentHistoryGroup(title = "test One")
+ val group2 = RecentHistoryGroup(title = "testSearchTerm")
+ val group3 = RecentHistoryGroup(title = "test two")
+ val highlight = RecentHistoryHighlight(group2.title, "")
+ val recentHistory: List<RecentlyVisitedItem> = listOf(group1, group2, group3, highlight)
+ val recentSyncedTab = RecentSyncedTab(
+ deviceDisplayName = "device1",
+ deviceType = mockk(relaxed = true),
+ title = "1",
+ url = "",
+ previewImageUrl = null,
+ )
+ val recentSyncedTabState: RecentSyncedTabState =
+ RecentSyncedTabState.Success(recentSyncedTabsList + recentSyncedTab)
+
+ appStore.dispatch(
+ AppAction.Change(
+ collections = collections,
+ mode = BrowsingMode.Private,
+ topSites = topSites,
+ showCollectionPlaceholder = true,
+ recentTabs = recentTabs,
+ recentBookmarks = recentBookmarks,
+ recentHistory = recentHistory,
+ recentSyncedTabState = recentSyncedTabState,
+ ),
+ ).join()
+
+ assertEquals(collections, appStore.state.collections)
+ assertEquals(topSites, appStore.state.topSites)
+ assertEquals(recentTabs, appStore.state.recentTabs)
+ assertEquals(recentBookmarks, appStore.state.recentBookmarks)
+ assertEquals(listOf(group1, group2, group3, highlight), appStore.state.recentHistory)
+ assertEquals(BrowsingMode.Private, appStore.state.mode)
+ assertEquals(
+ recentSyncedTabState,
+ appStore.state.recentSyncedTabState,
+ )
+ }
+
+ @Test
+ fun `Test selecting a Pocket recommendations category`() = runTest {
+ val otherStoriesCategory = PocketRecommendedStoriesCategory("other")
+ val anotherStoriesCategory = PocketRecommendedStoriesCategory("another")
+ val filteredStories = listOf(mockk<PocketStory>())
+ appStore = AppStore(
+ AppState(
+ pocketStoriesCategories = listOf(otherStoriesCategory, anotherStoriesCategory),
+ pocketStoriesCategoriesSelections = listOf(
+ PocketRecommendedStoriesSelectedCategory(otherStoriesCategory.name),
+ ),
+ ),
+ )
+
+ mockkStatic("org.mozilla.fenix.ext.AppStateKt") {
+ every { any<AppState>().getFilteredStories() } returns filteredStories
+
+ appStore.dispatch(AppAction.SelectPocketStoriesCategory("another")).join()
+
+ verify { any<AppState>().getFilteredStories() }
+ }
+
+ val selectedCategories = appStore.state.pocketStoriesCategoriesSelections
+ assertEquals(2, selectedCategories.size)
+ assertTrue(otherStoriesCategory.name === selectedCategories[0].name)
+ assertSame(filteredStories, appStore.state.pocketStories)
+ }
+
+ @Test
+ fun `Test deselecting a Pocket recommendations category`() = runTest {
+ val otherStoriesCategory = PocketRecommendedStoriesCategory("other")
+ val anotherStoriesCategory = PocketRecommendedStoriesCategory("another")
+ val filteredStories = listOf(mockk<PocketStory>())
+ appStore = AppStore(
+ AppState(
+ pocketStoriesCategories = listOf(otherStoriesCategory, anotherStoriesCategory),
+ pocketStoriesCategoriesSelections = listOf(
+ PocketRecommendedStoriesSelectedCategory(otherStoriesCategory.name),
+ PocketRecommendedStoriesSelectedCategory(anotherStoriesCategory.name),
+ ),
+ ),
+ )
+
+ mockkStatic("org.mozilla.fenix.ext.AppStateKt") {
+ every { any<AppState>().getFilteredStories() } returns filteredStories
+
+ appStore.dispatch(AppAction.DeselectPocketStoriesCategory("other")).join()
+
+ verify { any<AppState>().getFilteredStories() }
+ }
+
+ val selectedCategories = appStore.state.pocketStoriesCategoriesSelections
+ assertEquals(1, selectedCategories.size)
+ assertTrue(anotherStoriesCategory.name === selectedCategories[0].name)
+ assertSame(filteredStories, appStore.state.pocketStories)
+ }
+
+ @Test
+ fun `Test cleaning the list of Pocket stories`() = runTest {
+ appStore = AppStore(
+ AppState(
+ pocketStoriesCategories = listOf(mockk()),
+ pocketStoriesCategoriesSelections = listOf(mockk()),
+ pocketStories = listOf(mockk()),
+ pocketSponsoredStories = listOf(mockk()),
+ ),
+ )
+
+ appStore.dispatch(AppAction.PocketStoriesClean)
+ .join()
+
+ assertTrue(appStore.state.pocketStoriesCategories.isEmpty())
+ assertTrue(appStore.state.pocketStoriesCategoriesSelections.isEmpty())
+ assertTrue(appStore.state.pocketStories.isEmpty())
+ assertTrue(appStore.state.pocketSponsoredStories.isEmpty())
+ }
+
+ @Test
+ fun `Test updating the list of Pocket sponsored stories also updates the list of stories to show`() = runTest {
+ val story1 = PocketSponsoredStory(
+ id = 3,
+ title = "title",
+ url = "url",
+ imageUrl = "imageUrl",
+ sponsor = "sponsor",
+ shim = mockk(),
+ priority = 33,
+ caps = mockk(),
+ )
+ val story2 = story1.copy(imageUrl = "imageUrl2")
+
+ appStore = AppStore(AppState())
+
+ mockkStatic("org.mozilla.fenix.ext.AppStateKt") {
+ val firstFilteredStories = listOf(mockk<PocketSponsoredStory>())
+ every { any<AppState>().getFilteredStories() } returns firstFilteredStories
+ appStore.dispatch(AppAction.PocketSponsoredStoriesChange(listOf(story1, story2))).join()
+ assertTrue(appStore.state.pocketSponsoredStories.containsAll(listOf(story1, story2)))
+ assertEquals(firstFilteredStories, appStore.state.pocketStories)
+
+ val secondFilteredStories = firstFilteredStories + mockk<PocketRecommendedStory>()
+ every { any<AppState>().getFilteredStories() } returns secondFilteredStories
+ val updatedStories = listOf(story2.copy(title = "title3"))
+ appStore.dispatch(AppAction.PocketSponsoredStoriesChange(updatedStories)).join()
+ assertTrue(updatedStories.containsAll(appStore.state.pocketSponsoredStories))
+ assertEquals(secondFilteredStories, appStore.state.pocketStories)
+ }
+ }
+
+ @Test
+ fun `Test updating sponsored Pocket stories after being shown to the user`() = runTest {
+ val story1 = PocketSponsoredStory(
+ id = 3,
+ title = "title",
+ url = "url",
+ imageUrl = "imageUrl",
+ sponsor = "sponsor",
+ shim = mockk(),
+ priority = 33,
+ caps = PocketSponsoredStoryCaps(
+ currentImpressions = listOf(1, 2),
+ lifetimeCount = 11,
+ flightCount = 2,
+ flightPeriod = 11,
+ ),
+ )
+ val story2 = story1.copy(id = 22)
+ val story3 = story1.copy(id = 33)
+ val story4 = story1.copy(id = 44)
+ appStore = AppStore(
+ AppState(
+ pocketSponsoredStories = listOf(story1, story2, story3, story4),
+ ),
+ )
+
+ appStore.dispatch(AppAction.PocketStoriesShown(listOf(story1, story3))).join()
+
+ assertEquals(4, appStore.state.pocketSponsoredStories.size)
+ assertEquals(3, appStore.state.pocketSponsoredStories[0].caps.currentImpressions.size)
+ assertEquals(2, appStore.state.pocketSponsoredStories[1].caps.currentImpressions.size)
+ assertEquals(3, appStore.state.pocketSponsoredStories[2].caps.currentImpressions.size)
+ assertEquals(2, appStore.state.pocketSponsoredStories[3].caps.currentImpressions.size)
+ }
+
+ @Test
+ fun `Test updating the list of Pocket recommendations categories`() = runTest {
+ val otherStoriesCategory = PocketRecommendedStoriesCategory("other")
+ val anotherStoriesCategory = PocketRecommendedStoriesCategory("another")
+ appStore = AppStore(AppState())
+
+ mockkStatic("org.mozilla.fenix.ext.AppStateKt") {
+ val firstFilteredStories = listOf(mockk<PocketStory>())
+ every { any<AppState>().getFilteredStories() } returns firstFilteredStories
+
+ appStore.dispatch(
+ AppAction.PocketStoriesCategoriesChange(listOf(otherStoriesCategory, anotherStoriesCategory)),
+ ).join()
+ verify { any<AppState>().getFilteredStories() }
+ assertTrue(
+ appStore.state.pocketStoriesCategories.containsAll(
+ listOf(otherStoriesCategory, anotherStoriesCategory),
+ ),
+ )
+ assertSame(firstFilteredStories, appStore.state.pocketStories)
+
+ val updatedCategories = listOf(PocketRecommendedStoriesCategory("yetAnother"))
+ val secondFilteredStories = listOf(mockk<PocketStory>())
+ every { any<AppState>().getFilteredStories() } returns secondFilteredStories
+ appStore.dispatch(
+ AppAction.PocketStoriesCategoriesChange(
+ updatedCategories,
+ ),
+ ).join()
+ verify(exactly = 2) { any<AppState>().getFilteredStories() }
+ assertTrue(updatedCategories.containsAll(appStore.state.pocketStoriesCategories))
+ assertSame(secondFilteredStories, appStore.state.pocketStories)
+ }
+ }
+
+ @Test
+ fun `Test updating the list of selected Pocket recommendations categories`() = runTest {
+ val otherStoriesCategory = PocketRecommendedStoriesCategory("other")
+ val anotherStoriesCategory = PocketRecommendedStoriesCategory("another")
+ val selectedCategory = PocketRecommendedStoriesSelectedCategory("selected")
+ appStore = AppStore(AppState())
+
+ mockkStatic("org.mozilla.fenix.ext.AppStateKt") {
+ val firstFilteredStories = listOf(mockk<PocketStory>())
+ every { any<AppState>().getFilteredStories() } returns firstFilteredStories
+
+ appStore.dispatch(
+ AppAction.PocketStoriesCategoriesSelectionsChange(
+ storiesCategories = listOf(otherStoriesCategory, anotherStoriesCategory),
+ categoriesSelected = listOf(selectedCategory),
+ ),
+ ).join()
+ verify { any<AppState>().getFilteredStories() }
+ assertTrue(
+ appStore.state.pocketStoriesCategories.containsAll(
+ listOf(otherStoriesCategory, anotherStoriesCategory),
+ ),
+ )
+ assertTrue(
+ appStore.state.pocketStoriesCategoriesSelections.containsAll(listOf(selectedCategory)),
+ )
+ assertSame(firstFilteredStories, appStore.state.pocketStories)
+ }
+ }
+
+ @Test
+ fun `Test filtering out search groups`() {
+ val group1 = RecentHistoryGroup("title1")
+ val group2 = RecentHistoryGroup("title2")
+ val group3 = RecentHistoryGroup("title3")
+ val highLight1 = RecentHistoryHighlight("title1", "")
+ val highLight2 = RecentHistoryHighlight("title2", "")
+ val highLight3 = RecentHistoryHighlight("title3", "")
+ val recentHistory = listOf(group1, highLight1, group2, highLight2, group3, highLight3)
+
+ assertEquals(recentHistory, recentHistory.filterOut(null))
+ assertEquals(recentHistory, recentHistory.filterOut(""))
+ assertEquals(recentHistory, recentHistory.filterOut(" "))
+ assertEquals(recentHistory - group2, recentHistory.filterOut("Title2"))
+ assertEquals(recentHistory - group3, recentHistory.filterOut("title3"))
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/BackgroundServicesTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/BackgroundServicesTest.kt
new file mode 100644
index 0000000000..3723d49772
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/BackgroundServicesTest.kt
@@ -0,0 +1,153 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components
+
+import android.content.Context
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.confirmVerified
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.concept.sync.AccountObserver
+import mozilla.components.concept.sync.AuthType
+import mozilla.components.concept.sync.OAuthAccount
+import mozilla.components.service.nimbus.NimbusApi
+import mozilla.components.support.base.observer.ObserverRegistry
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.telemetry.glean.testing.GleanTestRule
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.SyncAuth
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.utils.Settings
+
+// For gleanTestRule
+@RunWith(FenixRobolectricTestRunner::class)
+class BackgroundServicesTest {
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @MockK
+ private lateinit var context: Context
+
+ @MockK
+ private lateinit var settings: Settings
+
+ @MockK
+ private lateinit var nimbus: NimbusApi
+
+ private lateinit var observer: TelemetryAccountObserver
+ private lateinit var registry: ObserverRegistry<AccountObserver>
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ every { settings.signedInFxaAccount = any() } just Runs
+
+ val mockComponents: Components = mockk()
+ every { mockComponents.settings } returns settings
+ every { mockComponents.nimbus } returns mockk {
+ every { sdk } returns nimbus
+ every { events } returns nimbus
+ }
+ every { context.components } returns mockComponents
+ every { nimbus.recordEvent(any()) } returns Unit
+
+ observer = TelemetryAccountObserver(context)
+ registry = ObserverRegistry<AccountObserver>().apply { register(observer) }
+ }
+
+ @Test
+ fun `telemetry account observer tracks sign in event`() {
+ val account = mockk<OAuthAccount>()
+
+ registry.notifyObservers { onAuthenticated(account, AuthType.Signin) }
+ assertEquals(1, SyncAuth.signIn.testGetValue()!!.size)
+ assertEquals(null, SyncAuth.signIn.testGetValue()!!.single().extra)
+ verify { settings.signedInFxaAccount = true }
+ confirmVerified(settings)
+ }
+
+ @Test
+ fun `telemetry account observer tracks sign up event`() {
+ val account = mockk<OAuthAccount>()
+
+ registry.notifyObservers { onAuthenticated(account, AuthType.Signup) }
+ assertEquals(1, SyncAuth.signUp.testGetValue()!!.size)
+ assertEquals(null, SyncAuth.signUp.testGetValue()!!.single().extra)
+ verify { settings.signedInFxaAccount = true }
+ confirmVerified(settings)
+ }
+
+ @Test
+ fun `telemetry account observer tracks pairing event`() {
+ val account = mockk<OAuthAccount>()
+
+ registry.notifyObservers { onAuthenticated(account, AuthType.Pairing) }
+ assertEquals(1, SyncAuth.paired.testGetValue()!!.size)
+ assertEquals(null, SyncAuth.paired.testGetValue()!!.single().extra)
+ verify { settings.signedInFxaAccount = true }
+ confirmVerified(settings)
+ }
+
+ @Test
+ fun `telemetry account observer tracks recovered event`() {
+ val account = mockk<OAuthAccount>()
+
+ registry.notifyObservers { onAuthenticated(account, AuthType.Recovered) }
+ assertEquals(1, SyncAuth.recovered.testGetValue()!!.size)
+ assertEquals(null, SyncAuth.recovered.testGetValue()!!.single().extra)
+ verify { settings.signedInFxaAccount = true }
+ confirmVerified(settings)
+ }
+
+ @Test
+ fun `telemetry account observer tracks external creation event with null action`() {
+ val account = mockk<OAuthAccount>()
+
+ registry.notifyObservers { onAuthenticated(account, AuthType.OtherExternal(null)) }
+ assertEquals(1, SyncAuth.otherExternal.testGetValue()!!.size)
+ assertEquals(null, SyncAuth.otherExternal.testGetValue()!!.single().extra)
+ verify { settings.signedInFxaAccount = true }
+ confirmVerified(settings)
+ }
+
+ @Test
+ fun `telemetry account observer tracks external creation event with some action`() {
+ val account = mockk<OAuthAccount>()
+
+ registry.notifyObservers { onAuthenticated(account, AuthType.OtherExternal("someAction")) }
+ assertEquals(1, SyncAuth.otherExternal.testGetValue()!!.size)
+ assertEquals(null, SyncAuth.otherExternal.testGetValue()!!.single().extra)
+ verify { settings.signedInFxaAccount = true }
+ confirmVerified(settings)
+ }
+
+ @Test
+ fun `telemetry account observer tracks sign out event`() {
+ registry.notifyObservers { onLoggedOut() }
+ assertEquals(1, SyncAuth.signOut.testGetValue()!!.size)
+ assertEquals(null, SyncAuth.signOut.testGetValue()!!.single().extra)
+ verify { settings.signedInFxaAccount = false }
+ confirmVerified(settings)
+ }
+
+ @Test
+ fun `telemetry account observer records nimbus event for logins`() {
+ observer.onAuthenticated(mockk(), AuthType.Signin)
+ verify {
+ nimbus.recordEvent("sync_auth.sign_in")
+ }
+ confirmVerified(nimbus)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/ChangeDetectionMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/ChangeDetectionMiddlewareTest.kt
new file mode 100644
index 0000000000..c8471db5d8
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/ChangeDetectionMiddlewareTest.kt
@@ -0,0 +1,102 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components
+
+import mozilla.components.lib.state.Action
+import mozilla.components.lib.state.Middleware
+import mozilla.components.lib.state.Reducer
+import mozilla.components.lib.state.State
+import mozilla.components.lib.state.Store
+import mozilla.components.support.test.ext.joinBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ChangeDetectionMiddlewareTest {
+ @Test
+ fun `GIVEN single state property change WHEN action changes that state THEN callback is invoked`() {
+ var capturedAction: TestAction? = null
+ var preCount = 0
+ var postCount = 0
+ val middleware: Middleware<TestState, TestAction> = ChangeDetectionMiddleware(
+ selector = { it.counter },
+ onChange = { action, pre, post ->
+ capturedAction = action
+ preCount = pre
+ postCount = post
+ },
+ )
+
+ val store = TestStore(
+ TestState(counter = preCount, enabled = false),
+ ::reducer,
+ listOf(middleware),
+ )
+
+ store.dispatch(TestAction.IncrementAction).joinBlocking()
+ assertTrue(capturedAction is TestAction.IncrementAction)
+ assertEquals(0, preCount)
+ assertEquals(1, postCount)
+
+ store.dispatch(TestAction.DecrementAction).joinBlocking()
+ assertTrue(capturedAction is TestAction.DecrementAction)
+ assertEquals(1, preCount)
+ assertEquals(0, postCount)
+ }
+
+ @Test
+ fun `GIVEN multiple state property change WHEN action changes any state THEN callback is invoked`() {
+ var capturedAction: TestAction? = null
+ var preState = listOf<Any>()
+ var postState = listOf<Any>()
+ val middleware: Middleware<TestState, TestAction> = ChangeDetectionMiddleware(
+ selector = { listOf(it.counter, it.enabled) },
+ onChange = { action, pre, post ->
+ capturedAction = action
+ preState = pre
+ postState = post
+ },
+ )
+
+ val store = TestStore(
+ TestState(counter = 0, enabled = false),
+ ::reducer,
+ listOf(middleware),
+ )
+
+ store.dispatch(TestAction.SetEnabled(true)).joinBlocking()
+ assertTrue(capturedAction is TestAction.SetEnabled)
+ assertEquals(false, preState[1])
+ assertEquals(true, postState[1])
+
+ store.dispatch(TestAction.SetEnabled(false)).joinBlocking()
+ assertTrue(capturedAction is TestAction.SetEnabled)
+ assertEquals(true, preState[1])
+ assertEquals(false, postState[1])
+ }
+
+ private class TestStore(
+ initialState: TestState,
+ reducer: Reducer<TestState, TestAction>,
+ middleware: List<Middleware<TestState, TestAction>>,
+ ) : Store<TestState, TestAction>(initialState, reducer, middleware)
+
+ private data class TestState(
+ val counter: Int,
+ val enabled: Boolean,
+ ) : State
+
+ private sealed class TestAction : Action {
+ object IncrementAction : TestAction()
+ object DecrementAction : TestAction()
+ data class SetEnabled(val enabled: Boolean) : TestAction()
+ }
+
+ private fun reducer(state: TestState, action: TestAction): TestState = when (action) {
+ is TestAction.IncrementAction -> state.copy(counter = state.counter + 1)
+ is TestAction.DecrementAction -> state.copy(counter = state.counter - 1)
+ is TestAction.SetEnabled -> state.copy(enabled = action.enabled)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/FenixSnackbarBehaviorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/FenixSnackbarBehaviorTest.kt
new file mode 100644
index 0000000000..2607d79ce0
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/FenixSnackbarBehaviorTest.kt
@@ -0,0 +1,256 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components
+
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import io.mockk.every
+import io.mockk.mockk
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.components.toolbar.ToolbarPosition
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class FenixSnackbarBehaviorTest {
+ private val snackbarContainer = mockk<FrameLayout>(relaxed = true)
+ private var snackbarLayoutParams = CoordinatorLayout.LayoutParams(0, 0)
+ private val dependency = View(testContext)
+ private val parent = CoordinatorLayout(testContext)
+
+ @Before
+ fun setup() {
+ every { snackbarContainer.layoutParams } returns snackbarLayoutParams
+ every { snackbarContainer.post(any()) } answers {
+ // Immediately run the given Runnable argument
+ val action: Runnable = firstArg()
+ action.run()
+ true
+ }
+ parent.addView(dependency)
+ }
+
+ @Test
+ fun `GIVEN no valid anchors are shown WHEN the snackbar is shown THEN don't anchor it`() {
+ val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.BOTTOM)
+
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+
+ assertSnackbarIsPlacedAtTheBottomOfTheScreen()
+ }
+
+ @Test
+ fun `GIVEN the dynamic download dialog is shown WHEN the snackbar is shown THEN place the snackbar above the dialog`() {
+ dependency.id = R.id.viewDynamicDownloadDialog
+ val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.BOTTOM)
+
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+
+ assertSnackbarPlacementAboveAnchor()
+ }
+
+ @Test
+ fun `GIVEN a bottom toolbar is shown WHEN the snackbar is shown THEN place the snackbar above the toolbar`() {
+ dependency.id = R.id.toolbar
+ val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.BOTTOM)
+
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+
+ assertSnackbarPlacementAboveAnchor()
+ }
+
+ @Test
+ fun `GIVEN a toolbar and a dynamic download dialog are shown WHEN the snackbar is shown THEN place the snackbar above the dialog`() {
+ listOf(R.id.viewDynamicDownloadDialog, R.id.toolbar).forEach {
+ parent.addView(View(testContext).apply { id = it })
+ }
+ val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.BOTTOM)
+
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+
+ assertSnackbarPlacementAboveAnchor(parent.findViewById(R.id.viewDynamicDownloadDialog))
+ }
+
+ @Test
+ fun `GIVEN a toolbar, a download dialog and a dynamic download dialog are shown WHEN the snackbar is shown THEN place the snackbar above the download dialog`() {
+ listOf(R.id.viewDynamicDownloadDialog, R.id.toolbar, R.id.startDownloadDialogContainer).forEach {
+ parent.addView(View(testContext).apply { id = it })
+ }
+ val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.BOTTOM)
+
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+
+ assertSnackbarPlacementAboveAnchor(parent.findViewById(R.id.startDownloadDialogContainer))
+ }
+
+ @Test
+ fun `GIVEN the snackbar is anchored to the dynamic download dialog and a bottom toolbar is shown WHEN the dialog is not shown anymore THEN place the snackbar above the toolbar`() {
+ val dialog = View(testContext)
+ .apply { id = R.id.viewDynamicDownloadDialog }
+ .also { parent.addView(it) }
+ val toolbar = View(testContext)
+ .apply { id = R.id.toolbar }
+ .also { parent.addView(it) }
+ val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.BOTTOM)
+
+ // Test the scenario where the dialog is invisible.
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+ assertSnackbarPlacementAboveAnchor(dialog)
+ dialog.visibility = View.GONE
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+ assertSnackbarPlacementAboveAnchor(toolbar)
+
+ // Test the scenario where the dialog is removed from parent.
+ dialog.visibility = View.VISIBLE
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+ assertSnackbarPlacementAboveAnchor(dialog)
+ parent.removeView(dialog)
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+ assertSnackbarPlacementAboveAnchor(toolbar)
+ }
+
+ @Test
+ fun `GIVEN the snackbar is anchored to a download dialog and another dynamic dialog is shown WHEN the dialog is not shown anymore THEN place the snackbar above the dynamic dialog`() {
+ val dialog = View(testContext)
+ .apply { id = R.id.startDownloadDialogContainer }
+ .also { parent.addView(it) }
+ val dynamicDialog = View(testContext)
+ .apply { id = R.id.viewDynamicDownloadDialog }
+ .also { parent.addView(it) }
+ val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.BOTTOM)
+
+ // Test the scenario where the dialog is invisible.
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+ assertSnackbarPlacementAboveAnchor(dialog)
+ dialog.visibility = View.GONE
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+ assertSnackbarPlacementAboveAnchor(dynamicDialog)
+
+ // Test the scenario where the dialog is removed from parent.
+ dialog.visibility = View.VISIBLE
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+ assertSnackbarPlacementAboveAnchor(dialog)
+ parent.removeView(dialog)
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+ assertSnackbarPlacementAboveAnchor(dynamicDialog)
+ }
+
+ @Test
+ fun `GIVEN the snackbar is anchored to a download dialog and a bottom toolbar is shown WHEN the dialog is not shown anymore THEN place the snackbar above the toolbar`() {
+ val dialog = View(testContext)
+ .apply { id = R.id.startDownloadDialogContainer }
+ .also { parent.addView(it) }
+ val toolbar = View(testContext)
+ .apply { id = R.id.toolbar }
+ .also { parent.addView(it) }
+ val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.BOTTOM)
+
+ // Test the scenario where the dialog is invisible.
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+ assertSnackbarPlacementAboveAnchor(dialog)
+ dialog.visibility = View.GONE
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+ assertSnackbarPlacementAboveAnchor(toolbar)
+
+ // Test the scenario where the dialog is removed from parent.
+ dialog.visibility = View.VISIBLE
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+ assertSnackbarPlacementAboveAnchor(dialog)
+ parent.removeView(dialog)
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+ assertSnackbarPlacementAboveAnchor(toolbar)
+ }
+
+ @Test
+ fun `GIVEN the snackbar is anchored to the bottom toolbar WHEN the toolbar is not shown anymore THEN place the snackbar at the bottom`() {
+ val toolbar = View(testContext)
+ .apply { id = R.id.toolbar }
+ .also { parent.addView(it) }
+ val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.BOTTOM)
+
+ // Test the scenario where the toolbar is invisible.
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+ assertSnackbarPlacementAboveAnchor(toolbar)
+ toolbar.visibility = View.GONE
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+ assertSnackbarIsPlacedAtTheBottomOfTheScreen()
+
+ // Test the scenario where the toolbar is removed from parent.
+ toolbar.visibility = View.VISIBLE
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+ assertSnackbarPlacementAboveAnchor(toolbar)
+ parent.removeView(toolbar)
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+ assertSnackbarIsPlacedAtTheBottomOfTheScreen()
+ }
+
+ @Test
+ fun `GIVEN the snackbar is anchored to the dynamic download dialog and a top toolbar is shown WHEN the dialog is not shown anymore THEN place the snackbar to the bottom`() {
+ val dialog = View(testContext)
+ .apply { id = R.id.viewDynamicDownloadDialog }
+ .also { parent.addView(it) }
+ View(testContext)
+ .apply { id = R.id.toolbar }
+ .also { parent.addView(it) }
+ val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.TOP)
+
+ // Test the scenario where the dialog is invisible.
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+ assertSnackbarPlacementAboveAnchor(dialog)
+ dialog.visibility = View.GONE
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+ assertSnackbarIsPlacedAtTheBottomOfTheScreen()
+
+ // Test the scenario where the dialog is removed from parent.
+ dialog.visibility = View.VISIBLE
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+ assertSnackbarPlacementAboveAnchor(dialog)
+ parent.removeView(dialog)
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+ assertSnackbarIsPlacedAtTheBottomOfTheScreen()
+ }
+
+ @Test
+ fun `GIVEN the snackbar is anchored based on a top toolbar WHEN the toolbar is not shown anymore THEN place the snackbar at the bottom`() {
+ val toolbar = View(testContext)
+ .apply { id = R.id.toolbar }
+ .also { parent.addView(it) }
+ val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.TOP)
+
+ // Test the scenario where the toolbar is invisible.
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+ assertSnackbarIsPlacedAtTheBottomOfTheScreen()
+ toolbar.visibility = View.GONE
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+ assertSnackbarIsPlacedAtTheBottomOfTheScreen()
+
+ // Test the scenario where the toolbar is removed from parent.
+ toolbar.visibility = View.VISIBLE
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+ assertSnackbarIsPlacedAtTheBottomOfTheScreen()
+ parent.removeView(toolbar)
+ behavior.layoutDependsOn(parent, snackbarContainer, dependency)
+ assertSnackbarIsPlacedAtTheBottomOfTheScreen()
+ }
+
+ private fun assertSnackbarPlacementAboveAnchor(anchor: View = dependency) {
+ assertEquals(anchor.id, snackbarLayoutParams.anchorId)
+ assertEquals(Gravity.TOP or Gravity.CENTER_HORIZONTAL, snackbarLayoutParams.anchorGravity)
+ assertEquals(Gravity.TOP or Gravity.CENTER_HORIZONTAL, snackbarLayoutParams.gravity)
+ }
+
+ private fun assertSnackbarIsPlacedAtTheBottomOfTheScreen() {
+ assertEquals(View.NO_ID, snackbarLayoutParams.anchorId)
+ assertEquals(Gravity.NO_GRAVITY, snackbarLayoutParams.anchorGravity)
+ assertEquals(Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL, snackbarLayoutParams.gravity)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/FindInPageIntegrationTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/FindInPageIntegrationTest.kt
new file mode 100644
index 0000000000..65f4333f6f
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/FindInPageIntegrationTest.kt
@@ -0,0 +1,255 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components
+
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.core.view.isVisible
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.browser.engine.gecko.GeckoEngineView
+import mozilla.components.browser.toolbar.BrowserToolbar
+import mozilla.components.concept.engine.EngineView
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertSame
+import org.junit.Test
+
+class FindInPageIntegrationTest {
+ // For ease of tests naming "find in page bar" is referred to as FIPB.
+
+ @Test
+ fun `GIVEN FIPB not shown WHEN prepareLayoutForFindBar is called for a dynamic top toolbar THEN toolbar is hidden and browser translated up`() {
+ val toolbar: BrowserToolbar = mockk(relaxed = true) {
+ every { height } returns 123
+ }
+ val engineViewParent: FrameLayout = mockk(relaxed = true)
+ val engineViewParentParams = ViewGroup.MarginLayoutParams(100, 100)
+ val toolbarInfo = FindInPageIntegration.ToolbarInfo(
+ toolbar = toolbar,
+ isToolbarDynamic = true,
+ isToolbarPlacedAtTop = true,
+ )
+ val feature = spyk(FindInPageIntegration(mockk(), null, mockk(), mockk(), toolbarInfo, 123)) {
+ every { getEngineViewsParentLayoutParams() } returns engineViewParentParams
+ every { getEngineViewParent() } returns engineViewParent
+ }
+
+ feature.prepareLayoutForFindBar()
+
+ verify { toolbar.isVisible = false }
+ verify { engineViewParent.translationY = 0f }
+ // MockKException: Missing calls inside verify { ... } block if verifying the bottomMargin setter on a mockk
+ // So I used a real instance and assert on the value actually set.
+ assertEquals(123, engineViewParentParams.bottomMargin)
+ }
+
+ @Test
+ fun `GIVEN FIPB not shown WHEN prepareLayoutForFindBar is called for a fixed top toolbar THEN toolbar is hidden and browser translated up`() {
+ val toolbar: BrowserToolbar = mockk(relaxed = true) {
+ every { height } returns 123
+ }
+ val engineViewParent: FrameLayout = mockk(relaxed = true)
+ val engineViewParentParams = ViewGroup.MarginLayoutParams(100, 100)
+ val toolbarInfo = FindInPageIntegration.ToolbarInfo(
+ toolbar = toolbar,
+ isToolbarDynamic = false,
+ isToolbarPlacedAtTop = true,
+ )
+ val feature = spyk(FindInPageIntegration(mockk(), null, mockk(), mockk(), toolbarInfo, 100)) {
+ every { getEngineViewsParentLayoutParams() } returns engineViewParentParams
+ every { getEngineViewParent() } returns engineViewParent
+ }
+
+ feature.prepareLayoutForFindBar()
+
+ verify { toolbar.isVisible = false }
+ verify { engineViewParent.translationY = -123f }
+ // MockKException: Missing calls inside verify { ... } block if verifying the bottomMargin setter on a mockk
+ // So a real instance is used to then assert on the value actually set.
+ assertEquals(0, engineViewParentParams.bottomMargin)
+ }
+
+ @Test
+ fun `GIVEN FIPB shown WHEN restorePreviousLayout is called for a dynamic top toolbar THEN toolbar is shown and browser translated down`() {
+ val toolbar: BrowserToolbar = mockk(relaxed = true) {
+ every { height } returns 123
+ }
+ val engineViewParent: FrameLayout = mockk(relaxed = true)
+ val engineViewParentParams = ViewGroup.MarginLayoutParams(100, 100)
+ val toolbarInfo = FindInPageIntegration.ToolbarInfo(
+ toolbar = toolbar,
+ isToolbarDynamic = true,
+ isToolbarPlacedAtTop = true,
+ )
+ val feature = spyk(FindInPageIntegration(mockk(), null, mockk(), mockk(), toolbarInfo, 50)) {
+ every { getEngineViewsParentLayoutParams() } returns engineViewParentParams
+ every { getEngineViewParent() } returns engineViewParent
+ }
+
+ feature.restorePreviousLayout()
+
+ verify { toolbar.isVisible = true }
+ verify { engineViewParent.translationY = 123f }
+ // MockKException: Missing calls inside verify { ... } block if verifying the bottomMargin setter on a mockk
+ // So I used a real instance and assert on the value actually set.
+ assertEquals(0, engineViewParentParams.bottomMargin)
+ }
+
+ @Test
+ fun `GIVEN FIPB shown WHEN restorePreviousLayout is called for a fixed top toolbar THEN toolbar is shown and browser translated down`() {
+ val toolbar: BrowserToolbar = mockk(relaxed = true) {
+ every { height } returns 123
+ }
+ val engineViewParent: FrameLayout = mockk(relaxed = true)
+ val engineViewParentParams = ViewGroup.MarginLayoutParams(100, 100)
+ val toolbarInfo = FindInPageIntegration.ToolbarInfo(
+ toolbar = toolbar,
+ isToolbarDynamic = false,
+ isToolbarPlacedAtTop = true,
+ )
+ val feature = spyk(FindInPageIntegration(mockk(), null, mockk(), mockk(), toolbarInfo, 50)) {
+ every { getEngineViewsParentLayoutParams() } returns engineViewParentParams
+ every { getEngineViewParent() } returns engineViewParent
+ }
+
+ feature.restorePreviousLayout()
+
+ verify { toolbar.isVisible = true }
+ verify { engineViewParent.translationY = 0f }
+ // MockKException: Missing calls inside verify { ... } block if verifying the bottomMargin setter on a mockk
+ // So I used a real instance and assert on the value actually set.
+ assertEquals(0, engineViewParentParams.bottomMargin)
+ }
+
+ @Test
+ fun `GIVEN FIPB not shown WHEN prepareLayoutForFindBar is called for a dynamic bottom toolbar THEN toolbar is hidden and browser is made smaller`() {
+ val toolbar: BrowserToolbar = mockk(relaxed = true) {
+ every { height } returns 123
+ }
+ val engineViewParent: FrameLayout = mockk(relaxed = true)
+ val engineViewParentParams = ViewGroup.MarginLayoutParams(100, 100)
+ val toolbarInfo = FindInPageIntegration.ToolbarInfo(
+ toolbar = toolbar,
+ isToolbarDynamic = true,
+ isToolbarPlacedAtTop = false,
+ )
+ val feature = spyk(FindInPageIntegration(mockk(), null, mockk(), mockk(), toolbarInfo, 50)) {
+ every { getEngineViewsParentLayoutParams() } returns engineViewParentParams
+ every { getEngineViewParent() } returns engineViewParent
+ }
+
+ feature.prepareLayoutForFindBar()
+
+ verify { toolbar.isVisible = false }
+ verify(exactly = 0) { engineViewParent.translationY = any() }
+ // MockKException: Missing calls inside verify { ... } block if verifying the bottomMargin setter on a mockk
+ // So I used a real instance and assert on the value actually set.
+ assertEquals(123, engineViewParentParams.bottomMargin)
+ }
+
+ @Test
+ fun `GIVEN FIPB not shown WHEN prepareLayoutForFindBar is called for a fixed bottom toolbar THEN toolbar is hidden and browser remains the same`() {
+ val toolbar: BrowserToolbar = mockk(relaxed = true) {
+ every { height } returns 123
+ }
+ val engineViewParent: FrameLayout = mockk(relaxed = true)
+ val engineViewParentParams = ViewGroup.MarginLayoutParams(100, 100)
+ val toolbarInfo = FindInPageIntegration.ToolbarInfo(
+ toolbar = toolbar,
+ isToolbarDynamic = false,
+ isToolbarPlacedAtTop = false,
+ )
+ val feature = spyk(FindInPageIntegration(mockk(), null, mockk(), mockk(), toolbarInfo, 50)) {
+ every { getEngineViewsParentLayoutParams() } returns engineViewParentParams
+ every { getEngineViewParent() } returns engineViewParent
+ }
+
+ feature.prepareLayoutForFindBar()
+
+ verify { toolbar.isVisible = false }
+ verify(exactly = 0) { engineViewParent.translationY = any() }
+ // MockKException: Missing calls inside verify { ... } block if verifying the bottomMargin setter on a mockk
+ // So I used a real instance and assert on the value actually set.
+ assertEquals(123, engineViewParentParams.bottomMargin)
+ }
+
+ @Test
+ fun `GIVEN FIPB shown WHEN restorePreviousLayout is called for a dynamic bottom toolbar THEN toolbar is shown and browser is made bigger`() {
+ val toolbar: BrowserToolbar = mockk(relaxed = true) {
+ every { height } returns 123
+ }
+ val engineViewParent: FrameLayout = mockk(relaxed = true)
+ val engineViewParentParams = ViewGroup.MarginLayoutParams(100, 100)
+ val toolbarInfo = FindInPageIntegration.ToolbarInfo(
+ toolbar = toolbar,
+ isToolbarDynamic = true,
+ isToolbarPlacedAtTop = false,
+ )
+ val feature = spyk(FindInPageIntegration(mockk(), null, mockk(), mockk(), toolbarInfo, 50)) {
+ every { getEngineViewsParentLayoutParams() } returns engineViewParentParams
+ every { getEngineViewParent() } returns engineViewParent
+ }
+
+ feature.restorePreviousLayout()
+
+ verify { toolbar.isVisible = true }
+ verify(exactly = 0) { engineViewParent.translationY = any() }
+ // MockKException: Missing calls inside verify { ... } block if verifying the bottomMargin setter on a mockk
+ // So I used a real instance and assert on the value actually set.
+ assertEquals(0, engineViewParentParams.bottomMargin)
+ }
+
+ @Test
+ fun `GIVEN FIPB shown WHEN restorePreviousLayout is called for a fixed bottom toolbar THEN toolbar is shown and browser remains the same`() {
+ val toolbar: BrowserToolbar = mockk(relaxed = true) {
+ every { height } returns 123
+ }
+ val engineViewParent: FrameLayout = mockk(relaxed = true)
+ val engineViewParentParams = ViewGroup.MarginLayoutParams(100, 100)
+ val toolbarInfo = FindInPageIntegration.ToolbarInfo(
+ toolbar = toolbar,
+ isToolbarDynamic = true,
+ isToolbarPlacedAtTop = false,
+ )
+ val feature = spyk(FindInPageIntegration(mockk(), null, mockk(), mockk(), toolbarInfo, 50)) {
+ every { getEngineViewsParentLayoutParams() } returns engineViewParentParams
+ every { getEngineViewParent() } returns engineViewParent
+ }
+
+ feature.restorePreviousLayout()
+
+ verify { toolbar.isVisible = true }
+ verify(exactly = 0) { engineViewParent.translationY = any() }
+ // MockKException: Missing calls inside verify { ... } block if verifying the bottomMargin setter on a mockk
+ // So I used a real instance and assert on the value actually set.
+ assertEquals(0, engineViewParentParams.bottomMargin)
+ }
+
+ @Test
+ fun `GIVEN FindInPageIntegration WHEN getEngineViewParent is called THEN it returns EngineView's layout parent`() {
+ val parent: FrameLayout = mockk()
+ val engineView: GeckoEngineView = mockk(relaxed = true)
+ every { (engineView as EngineView).asView().parent } returns parent
+
+ val feature = FindInPageIntegration(mockk(), null, mockk(), engineView, mockk(), 50)
+
+ assertSame(parent as View, feature.getEngineViewParent())
+ }
+
+ @Test
+ fun `GIVEN FindInPageIntegration WHEN getEngineViewsParentLayoutParams is called THEN it returns EngineView's layout parent MarginLayoutParams`() {
+ val parent: FrameLayout = mockk(relaxed = true) {
+ every { layoutParams } returns mockk<ViewGroup.MarginLayoutParams>(relaxed = true)
+ }
+ val engineView: GeckoEngineView = mockk(relaxed = true)
+ val feature = spyk(FindInPageIntegration(mockk(), null, mockk(), engineView, mockk(), 60))
+ every { feature.getEngineViewParent() } returns parent
+
+ assertSame(parent.layoutParams, feature.getEngineViewsParentLayoutParams())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/IntentProcessorTypeTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/IntentProcessorTypeTest.kt
new file mode 100644
index 0000000000..3f83540063
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/IntentProcessorTypeTest.kt
@@ -0,0 +1,134 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components
+
+import android.content.Intent
+import android.content.Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY
+import io.mockk.every
+import io.mockk.mockk
+import mozilla.components.feature.intent.processing.IntentProcessor
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.customtabs.ExternalAppBrowserActivity
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class IntentProcessorTypeTest {
+ @Before
+ fun setup() {
+ every { testContext.components.intentProcessors } returns mockk(relaxed = true)
+ }
+
+ @Test
+ fun `should open intent with flag launched from history`() {
+ val intent: Intent = mockk()
+ every { intent.flags } returns FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY
+
+ assertTrue(IntentProcessorType.EXTERNAL_APP.shouldOpenToBrowser(intent))
+ assertFalse(IntentProcessorType.NEW_TAB.shouldOpenToBrowser(intent))
+ assertFalse(IntentProcessorType.OTHER.shouldOpenToBrowser(intent))
+ }
+
+ @Test
+ fun `should open intent without flag launched from history`() {
+ val intent: Intent = mockk()
+ every { intent.flags } returns 0
+
+ assertTrue(IntentProcessorType.EXTERNAL_APP.shouldOpenToBrowser(intent))
+ assertTrue(IntentProcessorType.NEW_TAB.shouldOpenToBrowser(intent))
+ assertFalse(IntentProcessorType.OTHER.shouldOpenToBrowser(intent))
+ }
+
+ @Test
+ fun `get type for normal intent processor`() {
+ val processor = testContext.components.intentProcessors.intentProcessor
+ val type = testContext.components.intentProcessors.getType(processor)
+
+ assertEquals(IntentProcessorType.NEW_TAB, type)
+ assertEquals(HomeActivity::class.java.name, type.activityClassName)
+ }
+
+ @Test
+ fun `get type for private intent processor`() {
+ val processor = testContext.components.intentProcessors.privateIntentProcessor
+ val type = testContext.components.intentProcessors.getType(processor)
+
+ assertEquals(IntentProcessorType.NEW_TAB, type)
+ assertEquals(HomeActivity::class.java.name, type.activityClassName)
+ }
+
+ @Test
+ fun `get type for web notifications intent processor`() {
+ val processor = testContext.components.intentProcessors.webNotificationsIntentProcessor
+ val type = testContext.components.intentProcessors.getType(processor)
+
+ assertEquals(IntentProcessorType.NEW_TAB, type)
+ assertEquals(HomeActivity::class.java.name, type.activityClassName)
+ }
+
+ @Test
+ fun `get type for custom tab intent processor`() {
+ val processor = testContext.components.intentProcessors.customTabIntentProcessor
+ val type = testContext.components.intentProcessors.getType(processor)
+
+ assertEquals(IntentProcessorType.EXTERNAL_APP, type)
+ assertEquals(ExternalAppBrowserActivity::class.java.name, type.activityClassName)
+ }
+
+ @Test
+ fun `get type for private custom tab intent processor`() {
+ every { testContext.components.intentProcessors } returns mockk(relaxed = true)
+ val processor = testContext.components.intentProcessors.privateCustomTabIntentProcessor
+ val type = testContext.components.intentProcessors.getType(processor)
+
+ assertEquals(IntentProcessorType.EXTERNAL_APP, type)
+ assertEquals(ExternalAppBrowserActivity::class.java.name, type.activityClassName)
+ }
+
+ @Test
+ fun `get type for TWA intent processor`() {
+ val processor = testContext.components.intentProcessors.privateCustomTabIntentProcessor
+ val type = testContext.components.intentProcessors.getType(processor)
+
+ assertEquals(IntentProcessorType.EXTERNAL_APP, type)
+ assertEquals(ExternalAppBrowserActivity::class.java.name, type.activityClassName)
+ }
+
+ @Test
+ fun `get type for PWA intent processor`() {
+ val processor = testContext.components.intentProcessors.privateCustomTabIntentProcessor
+ val type = testContext.components.intentProcessors.getType(processor)
+
+ assertEquals(IntentProcessorType.EXTERNAL_APP, type)
+ assertEquals(ExternalAppBrowserActivity::class.java.name, type.activityClassName)
+ }
+
+ @Test
+ fun `get type for Deeplink intent processor`() {
+ val processor = testContext.components.intentProcessors.externalDeepLinkIntentProcessor
+ val type = testContext.components.intentProcessors.getType(processor)
+
+ assertEquals(IntentProcessorType.EXTERNAL_DEEPLINK, type)
+ assertEquals(HomeActivity::class.java.name, type.activityClassName)
+ }
+
+ @Test
+ fun `get type for generic intent processor`() {
+ val processor = object : IntentProcessor {
+ override fun process(intent: Intent) = true
+ }
+ val type = testContext.components.intentProcessors.getType(processor)
+
+ assertEquals(IntentProcessorType.OTHER, type)
+ assertEquals(HomeActivity::class.java.name, type.activityClassName)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/PermissionStorageTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/PermissionStorageTest.kt
new file mode 100644
index 0000000000..4398068f16
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/PermissionStorageTest.kt
@@ -0,0 +1,108 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components
+
+import androidx.paging.DataSource
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import mozilla.components.concept.engine.permission.SitePermissions
+import mozilla.components.concept.engine.permission.SitePermissionsStorage
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(FenixRobolectricTestRunner::class)
+class PermissionStorageTest {
+
+ @Test
+ fun `add permission`() = runTest {
+ val sitePermissions: SitePermissions = mockk(relaxed = true)
+ val sitePermissionsStorage: SitePermissionsStorage = mockk(relaxed = true)
+ val storage = PermissionStorage(testContext, this.coroutineContext, sitePermissionsStorage)
+
+ storage.add(sitePermissions, false)
+
+ coVerify { sitePermissionsStorage.save(sitePermissions, private = false) }
+ }
+
+ @Test
+ fun `add permission in privateBrowsing`() = runTest {
+ val sitePermissions: SitePermissions = mockk(relaxed = true)
+ val sitePermissionsStorage: SitePermissionsStorage = mockk(relaxed = true)
+ val storage = PermissionStorage(testContext, this.coroutineContext, sitePermissionsStorage)
+
+ storage.add(sitePermissions, true)
+
+ coVerify { sitePermissionsStorage.save(sitePermissions, private = true) }
+ }
+
+ @Test
+ fun `find sitePermissions by origin`() = runTest {
+ val sitePermissions: SitePermissions = mockk(relaxed = true)
+ val sitePermissionsStorage: SitePermissionsStorage = mockk(relaxed = true)
+ val storage = PermissionStorage(testContext, this.coroutineContext, sitePermissionsStorage)
+
+ coEvery { sitePermissionsStorage.findSitePermissionsBy(any(), any(), any()) } returns sitePermissions
+
+ val result = storage.findSitePermissionsBy("origin", false)
+
+ coVerify { sitePermissionsStorage.findSitePermissionsBy("origin", private = false) }
+
+ assertEquals(sitePermissions, result)
+ }
+
+ @Test
+ fun `update SitePermissions`() = runTest {
+ val sitePermissions: SitePermissions = mockk(relaxed = true)
+ val sitePermissionsStorage: SitePermissionsStorage = mockk(relaxed = true)
+ val storage = PermissionStorage(testContext, this.coroutineContext, sitePermissionsStorage)
+
+ storage.updateSitePermissions(sitePermissions, private = false)
+
+ coVerify { sitePermissionsStorage.update(sitePermissions, private = false) }
+ }
+
+ @Test
+ fun `get sitePermissions paged`() = runTest {
+ val dataSource: DataSource.Factory<Int, SitePermissions> = mockk(relaxed = true)
+ val sitePermissionsStorage: SitePermissionsStorage = mockk(relaxed = true)
+ val storage = PermissionStorage(testContext, this.coroutineContext, sitePermissionsStorage)
+
+ coEvery { sitePermissionsStorage.getSitePermissionsPaged() } returns dataSource
+
+ val result = storage.getSitePermissionsPaged()
+
+ coVerify { sitePermissionsStorage.getSitePermissionsPaged() }
+
+ assertEquals(dataSource, result)
+ }
+
+ @Test
+ fun `delete sitePermissions`() = runTest {
+ val sitePermissions: SitePermissions = mockk(relaxed = true)
+ val sitePermissionsStorage: SitePermissionsStorage = mockk(relaxed = true)
+ val storage = PermissionStorage(testContext, this.coroutineContext, sitePermissionsStorage)
+
+ storage.deleteSitePermissions(sitePermissions)
+
+ coVerify { sitePermissionsStorage.remove(sitePermissions, private = false) }
+ }
+
+ @Test
+ fun `delete all sitePermissions`() = runTest {
+ val sitePermissionsStorage: SitePermissionsStorage = mockk(relaxed = true)
+ val storage = PermissionStorage(testContext, this.coroutineContext, sitePermissionsStorage)
+
+ storage.deleteAllSitePermissions()
+
+ coVerify { sitePermissionsStorage.removeAll() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/PrivateShortcutCreateManagerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/PrivateShortcutCreateManagerTest.kt
new file mode 100644
index 0000000000..c4772e7377
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/PrivateShortcutCreateManagerTest.kt
@@ -0,0 +1,89 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components
+
+import android.app.PendingIntent
+import android.content.Intent
+import android.content.IntentSender
+import androidx.core.content.pm.ShortcutInfoCompat
+import androidx.core.content.pm.ShortcutManagerCompat
+import io.mockk.every
+import io.mockk.mockkStatic
+import io.mockk.slot
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.R
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.home.intent.StartSearchIntentProcessor
+import org.mozilla.fenix.utils.IntentUtils
+
+@RunWith(FenixRobolectricTestRunner::class)
+class PrivateShortcutCreateManagerTest {
+
+ @Before
+ fun setup() {
+ mockkStatic(ShortcutManagerCompat::class)
+ mockkStatic(PendingIntent::class)
+ }
+
+ @After
+ fun tearDown() {
+ unmockkStatic(ShortcutManagerCompat::class)
+ unmockkStatic(PendingIntent::class)
+ }
+
+ @Test
+ fun `GIVEN shortcut pinning is not supported WHEN createPrivateShortcut is called THEN do not create a pinned shortcut`() {
+ every { ShortcutManagerCompat.isRequestPinShortcutSupported(testContext) } returns false
+
+ PrivateShortcutCreateManager.createPrivateShortcut(testContext)
+
+ verify(exactly = 0) { ShortcutManagerCompat.requestPinShortcut(testContext, any(), any()) }
+ }
+
+ @Test
+ fun `GIVEN shortcut pinning is supported WHEN createPrivateShortcut is called THEN create a pinned shortcut`() {
+ val shortcut = slot<ShortcutInfoCompat>()
+ val intentSender = slot<IntentSender>()
+ val intent = slot<Intent>()
+
+ every { ShortcutManagerCompat.isRequestPinShortcutSupported(testContext) } returns true
+
+ PrivateShortcutCreateManager.createPrivateShortcut(testContext)
+
+ verify { PendingIntent.getActivity(testContext, 0, capture(intent), IntentUtils.defaultIntentPendingFlags or PendingIntent.FLAG_UPDATE_CURRENT) }
+ verify { ShortcutManagerCompat.requestPinShortcut(testContext, capture(shortcut), capture(intentSender)) }
+ `assert shortcutInfoCompat is build correctly`(shortcut.captured)
+ `assert homeScreenIntent is built correctly`(intent.captured)
+ }
+
+ private fun `assert shortcutInfoCompat is build correctly`(shortcutInfoCompat: ShortcutInfoCompat) {
+ assertEquals(testContext.getString(R.string.app_name_private_5, testContext.getString(R.string.app_name)), shortcutInfoCompat.shortLabel)
+ assertEquals(testContext.getString(R.string.app_name_private_5, testContext.getString(R.string.app_name)), shortcutInfoCompat.longLabel)
+ assertEquals(R.mipmap.ic_launcher_private_round, shortcutInfoCompat.icon.resId)
+ `assert homeActivity intent is built correctly`(shortcutInfoCompat.intent)
+ }
+
+ private fun `assert homeActivity intent is built correctly`(intent: Intent) {
+ assertEquals(Intent.ACTION_VIEW, intent.action)
+ assertEquals(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK, intent.flags)
+ assertEquals(HomeActivity::class.qualifiedName, intent.component?.className)
+ assertEquals(true, intent.extras?.getBoolean(HomeActivity.PRIVATE_BROWSING_MODE))
+ assertEquals(StartSearchIntentProcessor.PRIVATE_BROWSING_PINNED_SHORTCUT, intent.extras?.getString(HomeActivity.OPEN_TO_SEARCH))
+ }
+
+ private fun `assert homeScreenIntent is built correctly`(intent: Intent) {
+ assertEquals(Intent.ACTION_MAIN, intent.action)
+ assert(intent.categories.contains(Intent.CATEGORY_HOME))
+ assertEquals(Intent.FLAG_ACTIVITY_NEW_TASK, intent.flags)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/ReviewPromptControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/ReviewPromptControllerTest.kt
new file mode 100644
index 0000000000..ed89e094d2
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/ReviewPromptControllerTest.kt
@@ -0,0 +1,266 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components
+
+import com.google.android.play.core.review.ReviewManager
+import com.google.android.play.core.review.ReviewManagerFactory
+import kotlinx.coroutines.test.runTest
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.telemetry.glean.testing.GleanTestRule
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.ReviewPrompt
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+class TestReviewSettings(
+ override var numberOfAppLaunches: Int = 0,
+ var isDefault: Boolean = false,
+ override var lastReviewPromptTimeInMillis: Long = 0,
+) : ReviewSettings {
+ override val isDefaultBrowser: Boolean
+ get() = isDefault
+}
+
+@RunWith(FenixRobolectricTestRunner::class)
+class ReviewPromptControllerTest {
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ private lateinit var reviewManager: ReviewManager
+
+ @Before
+ fun setUp() {
+ reviewManager = ReviewManagerFactory.create(testContext)
+ }
+
+ @Test
+ fun promptReviewDoesNotSetMillis() = runTest {
+ var promptWasCalled = false
+ val settings = TestReviewSettings(
+ numberOfAppLaunches = 5,
+ isDefault = false,
+ lastReviewPromptTimeInMillis = 0L,
+ )
+
+ val controller = ReviewPromptController(
+ reviewManager,
+ settings,
+ { 100L },
+ { promptWasCalled = true },
+ )
+
+ controller.reviewPromptIsReady = true
+ controller.promptReview(HomeActivity())
+
+ assertEquals(settings.lastReviewPromptTimeInMillis, 0L)
+ assertFalse(promptWasCalled)
+ }
+
+ @Test
+ fun promptReviewSetsMillisIfSuccessful() = runTest {
+ var promptWasCalled = false
+ val settings = TestReviewSettings(
+ numberOfAppLaunches = 5,
+ isDefault = true,
+ lastReviewPromptTimeInMillis = 0L,
+ )
+
+ val controller = ReviewPromptController(
+ reviewManager,
+ settings,
+ { 100L },
+ { promptWasCalled = true },
+ )
+
+ controller.reviewPromptIsReady = true
+ controller.promptReview(HomeActivity())
+ assertEquals(100L, settings.lastReviewPromptTimeInMillis)
+ assertTrue(promptWasCalled)
+ }
+
+ @Test
+ fun promptReviewWillNotBeCalledIfNotReady() = runTest {
+ var promptWasCalled = false
+ val settings = TestReviewSettings(
+ numberOfAppLaunches = 5,
+ isDefault = true,
+ lastReviewPromptTimeInMillis = 0L,
+ )
+
+ val controller = ReviewPromptController(
+ reviewManager,
+ settings,
+ { 100L },
+ { promptWasCalled = true },
+ )
+
+ controller.promptReview(HomeActivity())
+ assertFalse(promptWasCalled)
+ }
+
+ @Test
+ fun promptReviewWillUnreadyPromptAfterCalled() = runTest {
+ var promptWasCalled = false
+ val settings = TestReviewSettings(
+ numberOfAppLaunches = 5,
+ isDefault = true,
+ lastReviewPromptTimeInMillis = 0L,
+ )
+
+ val controller = ReviewPromptController(
+ reviewManager,
+ settings,
+ { 100L },
+ { promptWasCalled = true },
+ )
+
+ controller.reviewPromptIsReady = true
+
+ assertTrue(controller.reviewPromptIsReady)
+ controller.promptReview(HomeActivity())
+
+ assertFalse(controller.reviewPromptIsReady)
+ assertTrue(promptWasCalled)
+ }
+
+ @Test
+ fun trackApplicationLaunch() {
+ val settings = TestReviewSettings(
+ numberOfAppLaunches = 4,
+ isDefault = true,
+ lastReviewPromptTimeInMillis = 0L,
+ )
+
+ val controller = ReviewPromptController(
+ reviewManager,
+ settings,
+ { 0L },
+ )
+
+ assertFalse(controller.reviewPromptIsReady)
+ assertEquals(4, settings.numberOfAppLaunches)
+
+ controller.trackApplicationLaunch()
+
+ assertEquals(5, settings.numberOfAppLaunches)
+ assertTrue(controller.reviewPromptIsReady)
+ }
+
+ @Test
+ fun shouldShowPrompt() {
+ val settings = TestReviewSettings(
+ numberOfAppLaunches = 5,
+ isDefault = true,
+ lastReviewPromptTimeInMillis = 0L,
+ )
+
+ val controller = ReviewPromptController(
+ reviewManager,
+ settings,
+ { TEST_TIME_NOW },
+ )
+
+ // Test first success criteria
+ controller.reviewPromptIsReady = true
+ assertTrue(controller.shouldShowPrompt())
+
+ // Test with last prompt approx 4 months earlier
+ settings.apply {
+ numberOfAppLaunches = 5
+ isDefault = true
+ lastReviewPromptTimeInMillis = MORE_THAN_4_MONTHS_FROM_TEST_TIME_NOW
+ }
+
+ controller.reviewPromptIsReady = true
+ assertTrue(controller.shouldShowPrompt())
+
+ // Test without being the default browser
+ settings.apply {
+ numberOfAppLaunches = 5
+ isDefault = false
+ lastReviewPromptTimeInMillis = 0L
+ }
+
+ controller.reviewPromptIsReady = true
+ assertFalse(controller.shouldShowPrompt())
+
+ // Test with number of app launches < 5
+ settings.apply {
+ numberOfAppLaunches = 4
+ isDefault = true
+ lastReviewPromptTimeInMillis = 0L
+ }
+
+ controller.reviewPromptIsReady = true
+ assertFalse(controller.shouldShowPrompt())
+
+ // Test with last prompt less than 4 months ago
+ settings.apply {
+ numberOfAppLaunches = 5
+ isDefault = true
+ lastReviewPromptTimeInMillis = LESS_THAN_4_MONTHS_FROM_TEST_TIME_NOW
+ }
+
+ controller.reviewPromptIsReady = true
+ assertFalse(controller.shouldShowPrompt())
+ }
+
+ @Test
+ fun reviewPromptWasDisplayed() {
+ testRecordReviewPromptEventRecordsTheExpectedData("isNoOp=false", "true")
+ }
+
+ @Test
+ fun reviewPromptWasNotDisplayed() {
+ testRecordReviewPromptEventRecordsTheExpectedData("isNoOp=true", "false")
+ }
+
+ @Test
+ fun reviewPromptDisplayStateUnknown() {
+ testRecordReviewPromptEventRecordsTheExpectedData(expected = "error")
+ }
+
+ private fun testRecordReviewPromptEventRecordsTheExpectedData(
+ reviewInfoArg: String = "",
+ expected: String,
+ ) {
+ val numberOfAppLaunches = 1
+ val reviewInfoAsString =
+ "ReviewInfo{pendingIntent=PendingIntent{5b613b1: android.os.BinderProxy@46c8096}, $reviewInfoArg}"
+ val datetime = Date(TEST_TIME_NOW)
+ val formattedNowLocalDatetime = SIMPLE_DATE_FORMAT.format(datetime)
+
+ assertNull(ReviewPrompt.promptAttempt.testGetValue())
+ recordReviewPromptEvent(reviewInfoAsString, numberOfAppLaunches, datetime)
+
+ val reviewPromptData = ReviewPrompt.promptAttempt.testGetValue()!!.last().extra!!
+ assertEquals(expected, reviewPromptData["prompt_was_displayed"])
+ assertEquals(numberOfAppLaunches, reviewPromptData["number_of_app_launches"]!!.toInt())
+ assertEquals(formattedNowLocalDatetime, reviewPromptData["local_datetime"])
+ }
+
+ companion object {
+ private const val TEST_TIME_NOW = 1598416882805L
+ private const val MORE_THAN_4_MONTHS_FROM_TEST_TIME_NOW = 1588048882804L
+ private const val LESS_THAN_4_MONTHS_FROM_TEST_TIME_NOW = 1595824882905L
+ private val SIMPLE_DATE_FORMAT by lazy {
+ SimpleDateFormat(
+ "yyyy-MM-dd'T'HH:mm:ss",
+ Locale.getDefault(),
+ )
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/StoreProviderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/StoreProviderTest.kt
new file mode 100644
index 0000000000..afe7e6e2c3
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/StoreProviderTest.kt
@@ -0,0 +1,91 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components
+
+import androidx.fragment.app.Fragment
+import kotlinx.coroutines.CoroutineScope
+import mozilla.components.lib.state.Action
+import mozilla.components.lib.state.State
+import mozilla.components.lib.state.Store
+import mozilla.components.support.test.robolectric.createAddedTestFragment
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class StoreProviderTest {
+
+ private class BasicState : State
+
+ private val basicStore = Store(BasicState(), { state, _: Action -> state })
+
+ @Test
+ fun `factory returns store provider`() {
+ var createCalled = false
+ val factory = StoreProviderFactory {
+ createCalled = true
+ basicStore
+ }
+
+ assertFalse(createCalled)
+
+ assertEquals(basicStore, factory.create(StoreProvider::class.java).store)
+
+ assertTrue(createCalled)
+ }
+
+ @Test
+ fun `get returns store`() {
+ val fragment = createAddedTestFragment { Fragment() }
+
+ val store = StoreProvider.get(fragment) { basicStore }
+ assertEquals(basicStore, store)
+ }
+
+ @Test
+ fun `get only calls createStore if needed`() {
+ val fragment = createAddedTestFragment { Fragment() }
+
+ var createCalled = false
+ val createStore: (CoroutineScope) -> Store<BasicState, Action> = {
+ createCalled = true
+ basicStore
+ }
+
+ StoreProvider.get(fragment, createStore)
+ assertTrue(createCalled)
+
+ createCalled = false
+ StoreProvider.get(fragment, createStore)
+ assertFalse(createCalled)
+ }
+
+ @Test
+ fun `WHEN store is created lazily THEN createStore is only invoked on access`() {
+ val fragment = createAddedTestFragment { Fragment() }
+
+ var createCalled = false
+ val createStore: (CoroutineScope) -> Store<BasicState, Action> = {
+ createCalled = true
+ basicStore
+ }
+
+ val store by fragment.lazyStore(createStore)
+ // The store is not created yet.
+ assertFalse(createCalled)
+
+ assertEquals(basicStore, store)
+ // The store is only created when it's used.
+ assertTrue(createCalled)
+
+ // The store is not created again.
+ createCalled = false
+ fragment.lazyStore(createStore).value
+ assertFalse(createCalled)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/TrackingProtectionPolicyFactoryTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/TrackingProtectionPolicyFactoryTest.kt
new file mode 100644
index 0000000000..53983d9875
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/TrackingProtectionPolicyFactoryTest.kt
@@ -0,0 +1,734 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy
+import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy.CookiePolicy
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.Config
+import org.mozilla.fenix.R
+import org.mozilla.fenix.ReleaseChannel
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class TrackingProtectionPolicyFactoryTest {
+
+ private lateinit var all: String
+ private lateinit var social: String
+ private lateinit var thirdParty: String
+ private lateinit var unvisited: String
+ private lateinit var private: String
+
+ @Before
+ fun setup() {
+ mockkObject(Config)
+ every { Config.channel } returns ReleaseChannel.Nightly
+
+ all = testContext.resources.getString(R.string.all)
+ social = testContext.resources.getString(R.string.social)
+ thirdParty = testContext.resources.getString(R.string.third_party)
+ unvisited = testContext.resources.getString(R.string.unvisited)
+ private = testContext.resources.getString(R.string.private_string)
+ }
+
+ @Test
+ fun `WHEN useStrictMode is true then SHOULD return strict mode`() {
+ val expected = TrackingProtectionPolicy.strict()
+
+ val factory = TrackingProtectionPolicyFactory(
+ mockSettings(useStrict = true),
+ testContext.resources,
+ )
+
+ val privateOnly =
+ factory.createTrackingProtectionPolicy(normalMode = false, privateMode = true)
+ val normalOnly =
+ factory.createTrackingProtectionPolicy(normalMode = true, privateMode = false)
+ val always = factory.createTrackingProtectionPolicy(normalMode = true, privateMode = true)
+ val none = factory.createTrackingProtectionPolicy(normalMode = false, privateMode = false)
+
+ expected.assertPolicyEquals(privateOnly, checkPrivacy = false)
+ expected.assertPolicyEquals(normalOnly, checkPrivacy = false)
+ expected.assertPolicyEquals(always, checkPrivacy = false)
+ TrackingProtectionPolicy.none().assertPolicyEquals(none, checkPrivacy = false)
+ }
+
+ @Test
+ fun `WHEN neither use strict nor use custom is true SHOULD return recommended mode`() {
+ val expected = TrackingProtectionPolicy.recommended()
+
+ val factory = TrackingProtectionPolicyFactory(
+ mockSettings(useStrict = false, useCustom = false),
+ testContext.resources,
+ )
+
+ val privateOnly =
+ factory.createTrackingProtectionPolicy(normalMode = false, privateMode = true)
+ val normalOnly =
+ factory.createTrackingProtectionPolicy(normalMode = true, privateMode = false)
+ val always = factory.createTrackingProtectionPolicy(normalMode = true, privateMode = true)
+ val none = factory.createTrackingProtectionPolicy(normalMode = false, privateMode = false)
+
+ expected.assertPolicyEquals(privateOnly, checkPrivacy = false)
+ expected.assertPolicyEquals(normalOnly, checkPrivacy = false)
+ expected.assertPolicyEquals(always, checkPrivacy = false)
+ TrackingProtectionPolicy.none().assertPolicyEquals(none, checkPrivacy = false)
+ }
+
+ @Test
+ fun `GIVEN custom policy WHEN should not block cookies THEN tracking policy should not block cookies`() {
+ val expected = TrackingProtectionPolicy.select(
+ cookiePolicy = CookiePolicy.ACCEPT_ALL,
+ trackingCategories = allTrackingCategories,
+ strictSocialTrackingProtection = true,
+ )
+
+ val factory = TrackingProtectionPolicyFactory(
+ settingsForCustom(shouldBlockCookiesInCustom = false),
+ testContext.resources,
+ )
+
+ val privateOnly =
+ factory.createTrackingProtectionPolicy(normalMode = false, privateMode = true)
+ val normalOnly =
+ factory.createTrackingProtectionPolicy(normalMode = true, privateMode = false)
+ val always = factory.createTrackingProtectionPolicy(normalMode = true, privateMode = true)
+
+ expected.assertPolicyEquals(privateOnly, checkPrivacy = false)
+ expected.assertPolicyEquals(normalOnly, checkPrivacy = false)
+ expected.assertPolicyEquals(always, checkPrivacy = false)
+ }
+
+ @Test
+ fun `GIVEN custom policy WHEN cookie policy block all THEN tracking policy should have cookie policy allow none`() {
+ val expected = TrackingProtectionPolicy.select(
+ cookiePolicy = CookiePolicy.ACCEPT_NONE,
+ trackingCategories = allTrackingCategories,
+ strictSocialTrackingProtection = true,
+ )
+
+ val factory = TrackingProtectionPolicyFactory(
+ settingsForCustom(
+ shouldBlockCookiesInCustom = true,
+ blockCookiesSelection = all,
+ ),
+ testContext.resources,
+ )
+
+ val privateOnly =
+ factory.createTrackingProtectionPolicy(normalMode = false, privateMode = true)
+ val normalOnly =
+ factory.createTrackingProtectionPolicy(normalMode = true, privateMode = false)
+ val always = factory.createTrackingProtectionPolicy(normalMode = true, privateMode = true)
+
+ expected.assertPolicyEquals(privateOnly, checkPrivacy = false)
+ expected.assertPolicyEquals(normalOnly, checkPrivacy = false)
+ expected.assertPolicyEquals(always, checkPrivacy = false)
+ }
+
+ @Test
+ fun `GIVEN TCP is enabled by nimbus WHEN applyTCPIfNeeded THEN cookie policy should be TCP`() {
+ val settings: Settings = mockk(relaxed = true)
+ every { settings.enabledTotalCookieProtection } returns true
+
+ val policies = arrayOf(
+ TrackingProtectionPolicy.strict(),
+ TrackingProtectionPolicy.recommended(),
+ TrackingProtectionPolicy.select(),
+ )
+
+ for (policy in policies) {
+ val adaptedPolicy = policy.applyTCPIfNeeded(settings)
+ assertEquals(
+ CookiePolicy.ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS,
+ adaptedPolicy.cookiePolicy,
+ )
+ }
+ }
+
+ fun `GIVEN TCP is NOT enabled by nimbus WHEN applyTCPIfNeeded THEN reuse cookie policy`() {
+ val settings: Settings = mockk(relaxed = true)
+
+ every { settings.enabledTotalCookieProtection } returns false
+
+ val policies = arrayOf(
+ TrackingProtectionPolicy.strict(),
+ TrackingProtectionPolicy.recommended(),
+ TrackingProtectionPolicy.select(),
+ )
+
+ for (policy in policies) {
+ val adaptedPolicy = policy.applyTCPIfNeeded(settings)
+ assertEquals(
+ policy.cookiePolicy,
+ adaptedPolicy.cookiePolicy,
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN custom policy WHEN cookie policy social THEN tracking policy should have cookie policy allow non-trackers`() {
+ val expected = TrackingProtectionPolicy.select(
+ cookiePolicy = CookiePolicy.ACCEPT_NON_TRACKERS,
+ trackingCategories = allTrackingCategories,
+ strictSocialTrackingProtection = true,
+ )
+
+ val factory = TrackingProtectionPolicyFactory(
+ settingsForCustom(
+ shouldBlockCookiesInCustom = true,
+ blockCookiesSelection = social,
+ ),
+ testContext.resources,
+ )
+
+ val privateOnly =
+ factory.createTrackingProtectionPolicy(normalMode = false, privateMode = true)
+ val normalOnly =
+ factory.createTrackingProtectionPolicy(normalMode = true, privateMode = false)
+ val always = factory.createTrackingProtectionPolicy(normalMode = true, privateMode = true)
+
+ expected.assertPolicyEquals(privateOnly, checkPrivacy = false)
+ expected.assertPolicyEquals(normalOnly, checkPrivacy = false)
+ expected.assertPolicyEquals(always, checkPrivacy = false)
+ }
+
+ @Test
+ fun `GIVEN custom policy WHEN cookie policy accept visited THEN tracking policy should have cookie policy allow visited`() {
+ val expected = TrackingProtectionPolicy.select(
+ cookiePolicy = CookiePolicy.ACCEPT_VISITED,
+ trackingCategories = allTrackingCategories,
+ strictSocialTrackingProtection = true,
+ )
+
+ val factory = TrackingProtectionPolicyFactory(
+ settingsForCustom(
+ shouldBlockCookiesInCustom = true,
+ blockCookiesSelection = unvisited,
+ ),
+ testContext.resources,
+ )
+
+ val privateOnly =
+ factory.createTrackingProtectionPolicy(normalMode = false, privateMode = true)
+ val normalOnly =
+ factory.createTrackingProtectionPolicy(normalMode = true, privateMode = false)
+ val always = factory.createTrackingProtectionPolicy(normalMode = true, privateMode = true)
+
+ expected.assertPolicyEquals(privateOnly, checkPrivacy = false)
+ expected.assertPolicyEquals(normalOnly, checkPrivacy = false)
+ expected.assertPolicyEquals(always, checkPrivacy = false)
+ }
+
+ @Test
+ fun `GIVEN custom policy WHEN blockTrackingContentInCustom in private windows only THEN strict social tracking protection should be false`() {
+ val privateFactory = TrackingProtectionPolicyFactory(
+ settingsForCustom(
+ shouldBlockCookiesInCustom = false,
+ blockTrackingContentInCustom = private,
+ ),
+ testContext.resources,
+ )
+
+ val privateOnly =
+ privateFactory.createTrackingProtectionPolicy(normalMode = false, privateMode = true)
+
+ assertFalse(privateOnly.strictSocialTrackingProtection!!)
+ }
+
+ @Test
+ fun `GIVEN custom policy WHEN blockTrackingContentInCustom in all windows THEN strict social tracking protection should be true`() {
+ val privateFactory = TrackingProtectionPolicyFactory(
+ settingsForCustom(
+ shouldBlockCookiesInCustom = false,
+ blockTrackingContentInCustom = all,
+ ),
+ testContext.resources,
+ )
+
+ val privateOnly =
+ privateFactory.createTrackingProtectionPolicy(normalMode = false, privateMode = true)
+
+ assertTrue(privateOnly.strictSocialTrackingProtection!!)
+ }
+
+ @Test
+ fun `GIVEN custom policy WHEN cookie policy block third party THEN tracking policy should have cookie policy allow first party`() {
+ val expected = TrackingProtectionPolicy.select(
+ cookiePolicy = CookiePolicy.ACCEPT_ONLY_FIRST_PARTY,
+ trackingCategories = allTrackingCategories,
+ strictSocialTrackingProtection = true,
+ )
+
+ val factory = TrackingProtectionPolicyFactory(
+ settingsForCustom(
+ shouldBlockCookiesInCustom = true,
+ blockCookiesSelection = thirdParty,
+ ),
+ testContext.resources,
+ )
+
+ val privateOnly =
+ factory.createTrackingProtectionPolicy(normalMode = false, privateMode = true)
+ val normalOnly =
+ factory.createTrackingProtectionPolicy(normalMode = true, privateMode = false)
+ val always = factory.createTrackingProtectionPolicy(normalMode = true, privateMode = true)
+
+ expected.assertPolicyEquals(privateOnly, checkPrivacy = false)
+ expected.assertPolicyEquals(normalOnly, checkPrivacy = false)
+ expected.assertPolicyEquals(always, checkPrivacy = false)
+ }
+
+ @Test
+ fun `GIVEN custom policy WHEN cookie policy is total protection THEN tracking policy should have cookie policy to block cross-site cookies`() {
+ val expected = TrackingProtectionPolicy.select(
+ cookiePolicy = CookiePolicy.ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS,
+ trackingCategories = allTrackingCategories,
+ strictSocialTrackingProtection = true,
+ )
+
+ val factory = TrackingProtectionPolicyFactory(
+ settingsForCustom(
+ shouldBlockCookiesInCustom = true,
+ blockCookiesSelection = "total-protection",
+ ),
+ testContext.resources,
+ )
+
+ val privateOnly =
+ factory.createTrackingProtectionPolicy(normalMode = false, privateMode = true)
+ val normalOnly =
+ factory.createTrackingProtectionPolicy(normalMode = true, privateMode = false)
+ val always = factory.createTrackingProtectionPolicy(normalMode = true, privateMode = true)
+
+ expected.assertPolicyEquals(privateOnly, checkPrivacy = false)
+ expected.assertPolicyEquals(normalOnly, checkPrivacy = false)
+ expected.assertPolicyEquals(always, checkPrivacy = false)
+ }
+
+ @Test
+ fun `GIVEN custom policy WHEN cookie policy unrecognized THEN tracking policy should have cookie policy block all`() {
+ val expected = TrackingProtectionPolicy.select(
+ cookiePolicy = CookiePolicy.ACCEPT_NONE,
+ trackingCategories = allTrackingCategories,
+ strictSocialTrackingProtection = true,
+ )
+
+ val factory = TrackingProtectionPolicyFactory(
+ settingsForCustom(
+ shouldBlockCookiesInCustom = true,
+ blockCookiesSelection = "some text!",
+ ),
+ testContext.resources,
+ )
+
+ val privateOnly =
+ factory.createTrackingProtectionPolicy(normalMode = false, privateMode = true)
+ val normalOnly =
+ factory.createTrackingProtectionPolicy(normalMode = true, privateMode = false)
+ val always = factory.createTrackingProtectionPolicy(normalMode = true, privateMode = true)
+
+ expected.assertPolicyEquals(privateOnly, checkPrivacy = false)
+ expected.assertPolicyEquals(normalOnly, checkPrivacy = false)
+ expected.assertPolicyEquals(always, checkPrivacy = false)
+ }
+
+ @Test
+ fun `all cookies_options_entry_values values should create policies without crashing`() {
+ testContext.resources.getStringArray(R.array.cookies_options_entry_values).forEach {
+ TrackingProtectionPolicyFactory(
+ settingsForCustom(
+ shouldBlockCookiesInCustom = true,
+ blockCookiesSelection = it,
+ ),
+ testContext.resources,
+ )
+ .createTrackingProtectionPolicy(normalMode = true, privateMode = true)
+ }
+ }
+
+ @Test
+ fun `factory should construct policies with privacy settings that match their inputs`() {
+ val allFactories = listOf(
+ TrackingProtectionPolicyFactory(
+ mockSettings(useStrict = true),
+ testContext.resources,
+ ),
+ TrackingProtectionPolicyFactory(
+ mockSettings(useStrict = false, useCustom = false),
+ testContext.resources,
+ ),
+ )
+
+ allFactories.map {
+ it.createTrackingProtectionPolicy(normalMode = true, privateMode = false)
+ }.forEach {
+ assertTrue(it.useForRegularSessions)
+ assertFalse(it.useForPrivateSessions)
+ }
+
+ allFactories.map {
+ it.createTrackingProtectionPolicy(normalMode = false, privateMode = true)
+ }.forEach {
+ assertTrue(it.useForPrivateSessions)
+ assertFalse(it.useForRegularSessions)
+ }
+
+ allFactories.map {
+ it.createTrackingProtectionPolicy(normalMode = true, privateMode = true)
+ }.forEach {
+ assertTrue(it.useForRegularSessions)
+ assertTrue(it.useForPrivateSessions)
+ }
+
+ // `normalMode = true, privateMode = true` can never be shown to the user
+ }
+
+ @Test
+ fun `factory should follow global ETP settings by default`() {
+ var useETPFactory = TrackingProtectionPolicyFactory(
+ mockSettings(useTrackingProtection = true),
+ testContext.resources,
+ )
+ var policy = useETPFactory.createTrackingProtectionPolicy()
+ assertTrue(policy.useForPrivateSessions)
+ assertTrue(policy.useForRegularSessions)
+
+ useETPFactory = TrackingProtectionPolicyFactory(
+ mockSettings(useTrackingProtection = false),
+ testContext.resources,
+ )
+ policy = useETPFactory.createTrackingProtectionPolicy()
+ assertEquals(policy, TrackingProtectionPolicy.none())
+ }
+
+ @Test
+ fun `custom tabs should respect their privacy rules`() {
+ val allSettings = listOf(
+ settingsForCustom(
+ shouldBlockCookiesInCustom = false,
+ blockTrackingContentInCustom = all,
+ ),
+ settingsForCustom(
+ shouldBlockCookiesInCustom = true,
+ blockCookiesSelection = all,
+ blockTrackingContentInCustom = all,
+ ),
+ settingsForCustom(
+ shouldBlockCookiesInCustom = true,
+ blockCookiesSelection = all,
+ blockTrackingContentInCustom = all,
+ ),
+ settingsForCustom(
+ shouldBlockCookiesInCustom = true,
+ blockCookiesSelection = unvisited,
+ blockTrackingContentInCustom = all,
+ ),
+ settingsForCustom(
+ shouldBlockCookiesInCustom = true,
+ blockCookiesSelection = thirdParty,
+ blockTrackingContentInCustom = all,
+ ),
+ settingsForCustom(
+ shouldBlockCookiesInCustom = true,
+ blockCookiesSelection = "some text!",
+ blockTrackingContentInCustom = all,
+ ),
+ )
+
+ val privateSettings = listOf(
+ settingsForCustom(
+ shouldBlockCookiesInCustom = false,
+ blockTrackingContentInCustom = private,
+ ),
+ settingsForCustom(
+ shouldBlockCookiesInCustom = true,
+ blockCookiesSelection = all,
+ blockTrackingContentInCustom = private,
+ ),
+ settingsForCustom(
+ shouldBlockCookiesInCustom = true,
+ blockCookiesSelection = all,
+ blockTrackingContentInCustom = private,
+ ),
+ settingsForCustom(
+ shouldBlockCookiesInCustom = true,
+ blockCookiesSelection = unvisited,
+ blockTrackingContentInCustom = private,
+ ),
+ settingsForCustom(
+ shouldBlockCookiesInCustom = true,
+ blockCookiesSelection = thirdParty,
+ blockTrackingContentInCustom = private,
+ ),
+ settingsForCustom(
+ shouldBlockCookiesInCustom = true,
+ blockCookiesSelection = "some text!",
+ blockTrackingContentInCustom = private,
+ ),
+ )
+
+ allSettings.map {
+ TrackingProtectionPolicyFactory(
+ it,
+ testContext.resources,
+ ).createTrackingProtectionPolicy(
+ normalMode = true,
+ privateMode = true,
+ )
+ }
+ .forEach {
+ assertTrue(it.useForRegularSessions)
+ assertTrue(it.useForPrivateSessions)
+ }
+
+ privateSettings.map {
+ TrackingProtectionPolicyFactory(
+ it,
+ testContext.resources,
+ ).createTrackingProtectionPolicy(
+ normalMode = true,
+ privateMode = true,
+ )
+ }
+ .forEach {
+ assertFalse(it.useForRegularSessions)
+ assertTrue(it.useForPrivateSessions)
+ }
+ }
+
+ @Test
+ fun `GIVEN custom policy WHEN default tracking policies THEN tracking policies should match default`() {
+ val defaultTrackingCategories = arrayOf(
+ TrackingProtectionPolicy.TrackingCategory.AD,
+ TrackingProtectionPolicy.TrackingCategory.ANALYTICS,
+ TrackingProtectionPolicy.TrackingCategory.SOCIAL,
+ TrackingProtectionPolicy.TrackingCategory.MOZILLA_SOCIAL,
+ )
+
+ val expected = TrackingProtectionPolicy.select(
+ cookiePolicy = CookiePolicy.ACCEPT_NONE,
+ trackingCategories = defaultTrackingCategories,
+ strictSocialTrackingProtection = false,
+ )
+
+ val factory = TrackingProtectionPolicyFactory(
+ settingsForCustom(
+ shouldBlockCookiesInCustom = true,
+ blockTrackingContent = false,
+ blockFingerprinters = false,
+ blockCryptominers = false,
+ ),
+ testContext.resources,
+ )
+ val actual = factory.createTrackingProtectionPolicy(normalMode = true, privateMode = true)
+
+ expected.assertPolicyEquals(actual, checkPrivacy = false)
+ }
+
+ @Test
+ fun `GIVEN custom policy WHEN all tracking policies THEN tracking policies should match all`() {
+ val expected = TrackingProtectionPolicy.select(
+ cookiePolicy = CookiePolicy.ACCEPT_NONE,
+ trackingCategories = allTrackingCategories,
+ strictSocialTrackingProtection = true,
+ )
+
+ val factory = TrackingProtectionPolicyFactory(
+ settingsForCustom(
+ shouldBlockCookiesInCustom = true,
+ blockTrackingContent = true,
+ blockFingerprinters = true,
+ blockCryptominers = true,
+ ),
+ testContext.resources,
+ )
+ val actual = factory.createTrackingProtectionPolicy(normalMode = true, privateMode = true)
+
+ expected.assertPolicyEquals(actual, checkPrivacy = false)
+ }
+
+ @Test
+ fun `GIVEN custom policy WHEN some tracking policies THEN tracking policies should match passed policies`() {
+ val someTrackingCategories = arrayOf(
+ TrackingProtectionPolicy.TrackingCategory.AD,
+ TrackingProtectionPolicy.TrackingCategory.ANALYTICS,
+ TrackingProtectionPolicy.TrackingCategory.SOCIAL,
+ TrackingProtectionPolicy.TrackingCategory.MOZILLA_SOCIAL,
+ TrackingProtectionPolicy.TrackingCategory.FINGERPRINTING,
+ )
+
+ val expected = TrackingProtectionPolicy.select(
+ cookiePolicy = CookiePolicy.ACCEPT_NONE,
+ trackingCategories = someTrackingCategories,
+ strictSocialTrackingProtection = false,
+ )
+
+ val factory = TrackingProtectionPolicyFactory(
+ settingsForCustom(
+ shouldBlockCookiesInCustom = true,
+ blockTrackingContent = false,
+ blockFingerprinters = true,
+ blockCryptominers = false,
+ blockRedirectTrackers = true,
+ ),
+ testContext.resources,
+ )
+ val actual = factory.createTrackingProtectionPolicy(normalMode = true, privateMode = true)
+
+ expected.assertPolicyEquals(actual, checkPrivacy = false)
+ }
+
+ @Test
+ fun `GIVEN custom policy WHEN some tracking policies THEN purge cookies`() {
+ val expected = TrackingProtectionPolicy.select(
+ cookiePolicy = CookiePolicy.ACCEPT_NONE,
+ trackingCategories = allTrackingCategories,
+ cookiePurging = true,
+ strictSocialTrackingProtection = true,
+ )
+
+ val factory = TrackingProtectionPolicyFactory(
+ settingsForCustom(shouldBlockCookiesInCustom = true),
+ testContext.resources,
+ )
+
+ val privateOnly =
+ factory.createTrackingProtectionPolicy(normalMode = false, privateMode = true)
+ val normalOnly =
+ factory.createTrackingProtectionPolicy(normalMode = true, privateMode = false)
+ val always = factory.createTrackingProtectionPolicy(normalMode = true, privateMode = true)
+
+ expected.assertPolicyEquals(privateOnly, checkPrivacy = false)
+ expected.assertPolicyEquals(normalOnly, checkPrivacy = false)
+ expected.assertPolicyEquals(always, checkPrivacy = false)
+ }
+
+ @Test
+ fun `GIVEN strict policy WHEN some tracking policies THEN purge cookies`() {
+ val expected = TrackingProtectionPolicy.strict()
+
+ val factory = TrackingProtectionPolicyFactory(
+ mockSettings(
+ useStrict = true,
+ useTrackingProtection = true,
+ ),
+ testContext.resources,
+ )
+
+ val privateOnly =
+ factory.createTrackingProtectionPolicy(normalMode = false, privateMode = true)
+ val normalOnly =
+ factory.createTrackingProtectionPolicy(normalMode = true, privateMode = false)
+ val always = factory.createTrackingProtectionPolicy(normalMode = true, privateMode = true)
+
+ assertEquals(privateOnly.cookiePurging, expected.cookiePurging)
+ assertEquals(normalOnly.cookiePurging, expected.cookiePurging)
+ assertEquals(always.cookiePurging, expected.cookiePurging)
+ }
+
+ @Test
+ fun `GIVEN standard policy WHEN some tracking policies THEN purge cookies`() {
+ val expected = TrackingProtectionPolicy.recommended()
+
+ val factory = TrackingProtectionPolicyFactory(
+ mockSettings(
+ useStrict = false,
+ useCustom = false,
+ useTrackingProtection = true,
+ ),
+ testContext.resources,
+ )
+
+ val privateOnly =
+ factory.createTrackingProtectionPolicy(normalMode = false, privateMode = true)
+ val normalOnly =
+ factory.createTrackingProtectionPolicy(normalMode = true, privateMode = false)
+ val always = factory.createTrackingProtectionPolicy(normalMode = true, privateMode = true)
+
+ assertEquals(privateOnly.cookiePurging, expected.cookiePurging)
+ assertEquals(normalOnly.cookiePurging, expected.cookiePurging)
+ assertEquals(always.cookiePurging, expected.cookiePurging)
+ }
+
+ private fun mockSettings(
+ useStrict: Boolean = false,
+ useCustom: Boolean = false,
+ useTrackingProtection: Boolean = false,
+ ): Settings = mockk {
+ every { enabledTotalCookieProtection } returns false
+ every { useStrictTrackingProtection } returns useStrict
+ every { useCustomTrackingProtection } returns useCustom
+ every { shouldUseTrackingProtection } returns useTrackingProtection
+ }
+
+ private fun settingsForCustom(
+ shouldBlockCookiesInCustom: Boolean,
+ blockTrackingContentInCustom: String = all, // ["private", "all"]
+ blockCookiesSelection: String = all, // values from R.array.cookies_options_entry_values
+ blockTrackingContent: Boolean = true,
+ blockFingerprinters: Boolean = true,
+ blockCryptominers: Boolean = true,
+ blockRedirectTrackers: Boolean = true,
+ ): Settings = mockSettings(useStrict = false, useCustom = true).apply {
+ every { blockTrackingContentSelectionInCustomTrackingProtection } returns blockTrackingContentInCustom
+
+ every { blockCookiesInCustomTrackingProtection } returns shouldBlockCookiesInCustom
+ every { blockCookiesSelectionInCustomTrackingProtection } returns blockCookiesSelection
+ every { blockTrackingContentInCustomTrackingProtection } returns blockTrackingContent
+ every { blockFingerprintersInCustomTrackingProtection } returns blockFingerprinters
+ every { blockCryptominersInCustomTrackingProtection } returns blockCryptominers
+ every { blockRedirectTrackersInCustomTrackingProtection } returns blockRedirectTrackers
+ }
+
+ private fun TrackingProtectionPolicy.assertPolicyEquals(
+ actual: TrackingProtectionPolicy,
+ checkPrivacy: Boolean,
+ ) {
+ assertEquals(this.cookiePolicy, actual.cookiePolicy)
+ val strictSocialTrackingProtection =
+ if (actual.useForPrivateSessions && !actual.useForRegularSessions) {
+ false
+ } else {
+ this.strictSocialTrackingProtection
+ }
+ assertEquals(strictSocialTrackingProtection, actual.strictSocialTrackingProtection)
+ // E.g., atm, RECOMMENDED == AD + ANALYTICS + SOCIAL + TEST + MOZILLA_SOCIAL + CRYPTOMINING.
+ // If all of these are set manually, the equality check should not fail
+ if (this.trackingCategories.toInt() != actual.trackingCategories.toInt()) {
+ assertArrayEquals(this.trackingCategories, actual.trackingCategories)
+ }
+
+ if (checkPrivacy) {
+ assertEquals(this.useForPrivateSessions, actual.useForPrivateSessions)
+ assertEquals(this.useForRegularSessions, actual.useForRegularSessions)
+ }
+ }
+
+ private fun Array<TrackingProtectionPolicy.TrackingCategory>.toInt(): Int {
+ return fold(initial = 0) { acc, next -> acc + next.id }
+ }
+
+ private val allTrackingCategories = arrayOf(
+ TrackingProtectionPolicy.TrackingCategory.AD,
+ TrackingProtectionPolicy.TrackingCategory.ANALYTICS,
+ TrackingProtectionPolicy.TrackingCategory.SOCIAL,
+ TrackingProtectionPolicy.TrackingCategory.MOZILLA_SOCIAL,
+ TrackingProtectionPolicy.TrackingCategory.SCRIPTS_AND_SUB_RESOURCES,
+ TrackingProtectionPolicy.TrackingCategory.FINGERPRINTING,
+ TrackingProtectionPolicy.TrackingCategory.CRYPTOMINING,
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/UrlRequestInterceptorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/UrlRequestInterceptorTest.kt
new file mode 100644
index 0000000000..f323adb3c9
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/UrlRequestInterceptorTest.kt
@@ -0,0 +1,281 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components
+
+import io.mockk.mockk
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.concept.engine.EngineSession.LoadUrlFlags
+import mozilla.components.concept.engine.EngineSession.LoadUrlFlags.Companion.ALLOW_ADDITIONAL_HEADERS
+import mozilla.components.concept.engine.EngineSession.LoadUrlFlags.Companion.BYPASS_CACHE
+import mozilla.components.concept.engine.EngineSession.LoadUrlFlags.Companion.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE
+import mozilla.components.concept.engine.request.RequestInterceptor
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class UrlRequestInterceptorTest {
+
+ private lateinit var engineSession: EngineSession
+
+ @Before
+ fun setup() {
+ engineSession = mockk(relaxed = true)
+ }
+
+ @Test
+ fun `GIVEN device is above threshold WHEN get additional headers is called THEN return the correct map of additional headers`() {
+ val isDeviceRamAboveThreshold = true
+ val urlRequestInterceptor = getUrlRequestInterceptor(
+ isDeviceRamAboveThreshold = isDeviceRamAboveThreshold,
+ )
+
+ assertEquals(
+ mapOf("X-Search-Subdivision" to "1"),
+ urlRequestInterceptor.getAdditionalHeaders(isDeviceRamAboveThreshold),
+ )
+ }
+
+ @Test
+ fun `GIVEN device is not above threshold WHEN get additional headers is called THEN return the correct map of additional headers`() {
+ val isDeviceRamAboveThreshold = false
+ val urlRequestInterceptor = getUrlRequestInterceptor(
+ isDeviceRamAboveThreshold = isDeviceRamAboveThreshold,
+ )
+
+ assertEquals(
+ mapOf("X-Search-Subdivision" to "0"),
+ urlRequestInterceptor.getAdditionalHeaders(isDeviceRamAboveThreshold),
+ )
+ }
+
+ @Test
+ fun `WHEN should intercept request is called THEN return the correct boolean value`() {
+ val urlRequestInterceptor = getUrlRequestInterceptor()
+
+ assertFalse(
+ urlRequestInterceptor.shouldInterceptRequest(
+ uri = "https://www.google.com",
+ isSubframeRequest = false,
+ ),
+ )
+ assertTrue(
+ urlRequestInterceptor.shouldInterceptRequest(
+ uri = "https://www.google.com/webhp",
+ isSubframeRequest = false,
+ ),
+ )
+ assertTrue(
+ urlRequestInterceptor.shouldInterceptRequest(
+ uri = "https://www.google.com/preferences",
+ isSubframeRequest = false,
+ ),
+ )
+ assertTrue(
+ urlRequestInterceptor.shouldInterceptRequest(
+ uri = "https://www.google.com/search?q=blue",
+ isSubframeRequest = false,
+ ),
+ )
+ assertTrue(
+ urlRequestInterceptor.shouldInterceptRequest(
+ uri = "https://www.google.ca/search?q=red",
+ isSubframeRequest = false,
+ ),
+ )
+ assertTrue(
+ urlRequestInterceptor.shouldInterceptRequest(
+ uri = "https://www.google.co.jp/search?q=red",
+ isSubframeRequest = false,
+ ),
+ )
+
+ assertFalse(
+ urlRequestInterceptor.shouldInterceptRequest(
+ uri = "https://getpocket.com",
+ isSubframeRequest = false,
+ ),
+ )
+ assertFalse(
+ urlRequestInterceptor.shouldInterceptRequest(
+ uri = "https://www.google.com/search?q=blue",
+ isSubframeRequest = true,
+ ),
+ )
+ assertFalse(
+ urlRequestInterceptor.shouldInterceptRequest(
+ uri = "https://www.google.com/recaptcha",
+ isSubframeRequest = true,
+ ),
+ )
+ }
+
+ @Test
+ fun `WHEN a Pocket request is loaded THEN request is not intercepted`() {
+ val uri = "https://getpocket.com"
+ val response = getUrlRequestInterceptor().onLoadRequest(
+ uri = uri,
+ )
+
+ assertNull(response)
+ }
+
+ @Test
+ fun `WHEN a Google preferences request is loaded THEN request is intercepted`() {
+ val uri = "https://www.google.com/preferences"
+
+ assertEquals(
+ RequestInterceptor.InterceptionResponse.Url(
+ url = uri,
+ flags = LoadUrlFlags.select(
+ LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE,
+ ALLOW_ADDITIONAL_HEADERS,
+ ),
+ additionalHeaders = mapOf(
+ "X-Search-Subdivision" to "0",
+ ),
+ ),
+ getUrlRequestInterceptor().onLoadRequest(
+ uri = uri,
+ ),
+ )
+ }
+
+ @Test
+ fun `WHEN a Google request end in #ip=1 is loaded THEN request bypass cache`() {
+ val uri = "https://www.google.com/search?q=test&ie=utf-8#ip=1"
+ assertEquals(
+ RequestInterceptor.InterceptionResponse.Url(
+ url = uri,
+ flags = LoadUrlFlags.select(
+ BYPASS_CACHE,
+ LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE,
+ ALLOW_ADDITIONAL_HEADERS,
+ ),
+ additionalHeaders = mapOf(
+ "X-Search-Subdivision" to "0",
+ ),
+ ),
+ getUrlRequestInterceptor().onLoadRequest(
+ uri = uri,
+ ),
+ )
+ }
+
+ @Test
+ fun `WHEN a Google search request is loaded THEN request is intercepted`() {
+ val uri = "https://www.google.com/search?q=blue"
+
+ assertEquals(
+ RequestInterceptor.InterceptionResponse.Url(
+ url = uri,
+ flags = LoadUrlFlags.select(
+ LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE,
+ ALLOW_ADDITIONAL_HEADERS,
+ ),
+ additionalHeaders = mapOf(
+ "X-Search-Subdivision" to "1",
+ ),
+ ),
+ getUrlRequestInterceptor(isDeviceRamAboveThreshold = true).onLoadRequest(
+ uri = uri,
+ ),
+ )
+
+ assertEquals(
+ RequestInterceptor.InterceptionResponse.Url(
+ url = uri,
+ flags = LoadUrlFlags.select(
+ LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE,
+ ALLOW_ADDITIONAL_HEADERS,
+ ),
+ additionalHeaders = mapOf(
+ "X-Search-Subdivision" to "0",
+ ),
+ ),
+ getUrlRequestInterceptor(isDeviceRamAboveThreshold = false).onLoadRequest(
+ uri = uri,
+ ),
+ )
+ }
+
+ @Test
+ fun `WHEN a Google search request with a ca TLD request is loaded THEN request is intercepted`() {
+ val uri = "https://www.google.ca/search?q=red"
+
+ assertEquals(
+ RequestInterceptor.InterceptionResponse.Url(
+ url = uri,
+ flags = LoadUrlFlags.select(
+ LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE,
+ ALLOW_ADDITIONAL_HEADERS,
+ ),
+ additionalHeaders = mapOf(
+ "X-Search-Subdivision" to "1",
+ ),
+ ),
+ getUrlRequestInterceptor(isDeviceRamAboveThreshold = true).onLoadRequest(
+ uri = uri,
+ ),
+ )
+
+ assertEquals(
+ RequestInterceptor.InterceptionResponse.Url(
+ url = uri,
+ flags = LoadUrlFlags.select(
+ LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE,
+ ALLOW_ADDITIONAL_HEADERS,
+ ),
+ additionalHeaders = mapOf(
+ "X-Search-Subdivision" to "0",
+ ),
+ ),
+ getUrlRequestInterceptor(isDeviceRamAboveThreshold = false).onLoadRequest(
+ uri = uri,
+ ),
+ )
+ }
+
+ @Test
+ fun `WHEN a Google subframe request is loaded THEN request is not intercepted`() {
+ val uri = "https://www.google.com/search?q=blue"
+
+ assertNull(
+ getUrlRequestInterceptor(isDeviceRamAboveThreshold = true).onLoadRequest(
+ uri = uri,
+ isSubframeRequest = true,
+ ),
+ )
+ }
+
+ private fun getUrlRequestInterceptor(isDeviceRamAboveThreshold: Boolean = false) =
+ UrlRequestInterceptor(
+ isDeviceRamAboveThreshold = isDeviceRamAboveThreshold,
+ )
+
+ private fun UrlRequestInterceptor.onLoadRequest(
+ uri: String,
+ lastUri: String? = null,
+ hasUserGesture: Boolean = false,
+ isSameDomain: Boolean = false,
+ isRedirect: Boolean = false,
+ isDirectNavigation: Boolean = false,
+ isSubframeRequest: Boolean = false,
+ ) = this.onLoadRequest(
+ engineSession = engineSession,
+ uri = uri,
+ lastUri = lastUri,
+ hasUserGesture = hasUserGesture,
+ isSameDomain = isSameDomain,
+ isRedirect = isRedirect,
+ isDirectNavigation = isDirectNavigation,
+ isSubframeRequest = isSubframeRequest,
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/accounts/FenixAccountManagerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/accounts/FenixAccountManagerTest.kt
new file mode 100644
index 0000000000..dd72e4bd66
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/accounts/FenixAccountManagerTest.kt
@@ -0,0 +1,116 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.accounts
+
+import android.content.Context
+import io.mockk.every
+import io.mockk.mockk
+import mozilla.components.concept.sync.OAuthAccount
+import mozilla.components.concept.sync.Profile
+import mozilla.components.service.fxa.manager.FxaAccountManager
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertSame
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.ext.components
+
+class FenixAccountManagerTest {
+
+ private lateinit var fenixFxaManager: FenixAccountManager
+ private lateinit var accountManagerComponent: FxaAccountManager
+ private lateinit var context: Context
+ private lateinit var account: OAuthAccount
+ private lateinit var profile: Profile
+
+ @Before
+ fun setUp() {
+ context = mockk(relaxed = true)
+ account = mockk(relaxed = true)
+ profile = mockk(relaxed = true)
+ accountManagerComponent = mockk(relaxed = true)
+ }
+
+ @Test
+ fun `GIVEN an account does not exist WHEN accountProfileEmail is called THEN it returns null`() {
+ every { accountManagerComponent.authenticatedAccount() } returns null
+ every { accountManagerComponent.accountProfile() } returns null
+ every { context.components.backgroundServices.accountManager } returns accountManagerComponent
+ fenixFxaManager = FenixAccountManager(context)
+
+ val result = fenixFxaManager.accountProfileEmail
+
+ assertEquals(null, result)
+ }
+
+ @Test
+ fun `GIVEN an account exists but needs to be re-authenticated WHEN accountProfileEmail is called THEN it returns null`() {
+ every { accountManagerComponent.authenticatedAccount() } returns account
+ every { accountManagerComponent.accountProfile() } returns profile
+ every { accountManagerComponent.accountProfile()?.email } returns "firefoxIsFun@test.com"
+ every { accountManagerComponent.accountNeedsReauth() } returns true
+ every { context.components.backgroundServices.accountManager } returns accountManagerComponent
+ fenixFxaManager = FenixAccountManager(context)
+
+ val result = fenixFxaManager.accountProfileEmail
+
+ assertEquals(null, result)
+ }
+
+ @Test
+ fun `GIVEN an account exists and doesn't need to be re-authenticated WHEN accountProfileEmail is called THEN it returns the associated email address`() {
+ every { accountManagerComponent.authenticatedAccount() } returns account
+ every { accountManagerComponent.accountProfile() } returns profile
+ val accountEmail = "firefoxIsFun@test.com"
+ every { accountManagerComponent.accountProfile()?.email } returns accountEmail
+ every { accountManagerComponent.accountNeedsReauth() } returns false
+ every { context.components.backgroundServices.accountManager } returns accountManagerComponent
+ fenixFxaManager = FenixAccountManager(context)
+
+ val result = fenixFxaManager.accountProfileEmail
+
+ assertEquals(accountEmail, result)
+ }
+
+ @Test
+ fun `GIVEN no account exists WHEN accountState is called THEN it returns AccountState#NO_ACCOUNT`() {
+ every { context.components.backgroundServices.accountManager } returns accountManagerComponent
+ every { accountManagerComponent.authenticatedAccount() } returns null
+ fenixFxaManager = FenixAccountManager(context)
+
+ assertSame(AccountState.NO_ACCOUNT, fenixFxaManager.accountState)
+
+ // No account but signed in should not be possible. Test protecting against such a regression.
+ every { accountManagerComponent.accountNeedsReauth() } returns false
+ assertSame(AccountState.NO_ACCOUNT, fenixFxaManager.accountState)
+
+ // No account and signed out still means no account. Test protecting against such a regression.
+ every { accountManagerComponent.accountNeedsReauth() } returns true
+ assertSame(AccountState.NO_ACCOUNT, fenixFxaManager.accountState)
+ }
+
+ @Test
+ fun `GIVEN an account exists but needs to be re-authenticated WHEN accountState is called THEN it returns AccountState#NEEDS_REAUTHENTICATION`() {
+ every { context.components.backgroundServices.accountManager } returns accountManagerComponent
+ every { accountManagerComponent.authenticatedAccount() } returns mockk()
+ every { accountManagerComponent.accountNeedsReauth() } returns true
+ fenixFxaManager = FenixAccountManager(context)
+
+ val result = fenixFxaManager.accountState
+
+ assertSame(AccountState.NEEDS_REAUTHENTICATION, result)
+ }
+
+ @Test
+ fun `GIVEN an account exists and doesn't need to be re-authenticated WHEN accountState is called THEN it returns AccountState#AUTHENTICATED`() {
+ every { context.components.backgroundServices.accountManager } returns accountManagerComponent
+ every { accountManagerComponent.authenticatedAccount() } returns mockk()
+ every { accountManagerComponent.accountNeedsReauth() } returns false
+ fenixFxaManager = FenixAccountManager(context)
+
+ val result = fenixFxaManager.accountState
+
+ assertSame(AccountState.AUTHENTICATED, result)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/appstate/AppActionTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/appstate/AppActionTest.kt
new file mode 100644
index 0000000000..f953b271e1
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/appstate/AppActionTest.kt
@@ -0,0 +1,27 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.appstate
+
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.middleware.CaptureActionsMiddleware
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.mozilla.fenix.components.AppStore
+
+class AppActionTest {
+
+ private val capture = CaptureActionsMiddleware<AppState, AppAction>()
+ private val appStore = AppStore(middlewares = listOf(capture))
+
+ @Test
+ fun `WHEN UpdateInactiveExpanded is dispatched THEN update inactiveTabsExpanded`() {
+ assertFalse(appStore.state.inactiveTabsExpanded)
+
+ appStore.dispatch(AppAction.UpdateInactiveExpanded(true)).joinBlocking()
+
+ assertTrue(appStore.state.inactiveTabsExpanded)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/appstate/AppStoreReducerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/appstate/AppStoreReducerTest.kt
new file mode 100644
index 0000000000..f1b7849361
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/appstate/AppStoreReducerTest.kt
@@ -0,0 +1,140 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.appstate
+
+import io.mockk.mockk
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.lib.crash.Crash.NativeCodeCrash
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.mozilla.fenix.browser.browsingmode.BrowsingMode
+import org.mozilla.fenix.components.appstate.AppAction.AddNonFatalCrash
+import org.mozilla.fenix.components.appstate.AppAction.RemoveAllNonFatalCrashes
+import org.mozilla.fenix.components.appstate.AppAction.RemoveNonFatalCrash
+import org.mozilla.fenix.components.appstate.AppAction.UpdateInactiveExpanded
+
+class AppStoreReducerTest {
+ @Test
+ fun `GIVEN a new value for inactiveTabsExpanded WHEN UpdateInactiveExpanded is called THEN update the current value`() {
+ val initialState = AppState(
+ inactiveTabsExpanded = true,
+ )
+
+ var updatedState = AppStoreReducer.reduce(initialState, UpdateInactiveExpanded(false))
+ assertFalse(updatedState.inactiveTabsExpanded)
+
+ updatedState = AppStoreReducer.reduce(updatedState, UpdateInactiveExpanded(true))
+ assertTrue(updatedState.inactiveTabsExpanded)
+ }
+
+ @Test
+ fun `GIVEN a Crash WHEN AddNonFatalCrash is called THEN add that Crash to the current list`() {
+ val initialState = AppState()
+ val crash1: NativeCodeCrash = mockk()
+ val crash2: NativeCodeCrash = mockk()
+
+ var updatedState = AppStoreReducer.reduce(initialState, AddNonFatalCrash(crash1))
+ assertTrue(listOf(crash1).containsAll(updatedState.nonFatalCrashes))
+
+ updatedState = AppStoreReducer.reduce(updatedState, AddNonFatalCrash(crash2))
+ assertTrue(listOf(crash1, crash2).containsAll(updatedState.nonFatalCrashes))
+ }
+
+ @Test
+ fun `GIVEN a Crash WHEN RemoveNonFatalCrash is called THEN remove that Crash from the current list`() {
+ val crash1: NativeCodeCrash = mockk()
+ val crash2: NativeCodeCrash = mockk()
+ val initialState = AppState(
+ nonFatalCrashes = listOf(crash1, crash2),
+ )
+
+ var updatedState = AppStoreReducer.reduce(initialState, RemoveNonFatalCrash(crash1))
+ assertTrue(listOf(crash2).containsAll(updatedState.nonFatalCrashes))
+
+ updatedState = AppStoreReducer.reduce(updatedState, RemoveNonFatalCrash(mockk()))
+ assertTrue(listOf(crash2).containsAll(updatedState.nonFatalCrashes))
+
+ updatedState = AppStoreReducer.reduce(updatedState, RemoveNonFatalCrash(crash2))
+ assertTrue(updatedState.nonFatalCrashes.isEmpty())
+ }
+
+ @Test
+ fun `GIVEN crashes exist in State WHEN RemoveAllNonFatalCrashes is called THEN clear the current list of crashes`() {
+ val initialState = AppState(
+ nonFatalCrashes = listOf(mockk(), mockk()),
+ )
+
+ val updatedState = AppStoreReducer.reduce(initialState, RemoveAllNonFatalCrashes)
+
+ assertTrue(updatedState.nonFatalCrashes.isEmpty())
+ }
+
+ @Test
+ fun `GIVEN mode is private WHEN selected tab changes to normal mode THEN state is updated to normal mode`() {
+ val initialState = AppState(
+ selectedTabId = null,
+ mode = BrowsingMode.Private,
+ )
+
+ val updatedState = AppStoreReducer.reduce(
+ initialState,
+ AppAction.SelectedTabChanged(createTab("", private = false)),
+ )
+
+ assertFalse(updatedState.mode.isPrivate)
+ }
+
+ @Test
+ fun `GIVEN mode is normal WHEN selected tab changes to private mode THEN state is updated to private mode`() {
+ val initialState = AppState(
+ selectedTabId = null,
+ mode = BrowsingMode.Normal,
+ )
+
+ val updatedState = AppStoreReducer.reduce(
+ initialState,
+ AppAction.SelectedTabChanged(createTab("", private = true)),
+ )
+
+ assertTrue(updatedState.mode.isPrivate)
+ }
+
+ @Test
+ fun `WHEN selected tab changes to a tab in the same mode THEN mode is unchanged`() {
+ val initialState = AppState(
+ selectedTabId = null,
+ mode = BrowsingMode.Normal,
+ )
+
+ val updatedState = AppStoreReducer.reduce(
+ initialState,
+ AppAction.SelectedTabChanged(createTab("", private = false)),
+ )
+
+ assertFalse(updatedState.mode.isPrivate)
+ }
+
+ @Test
+ fun `WHEN UpdateSearchDialogVisibility is called THEN isSearchDialogVisible gets updated`() {
+ val initialState = AppState()
+
+ assertFalse(initialState.isSearchDialogVisible)
+
+ var updatedState = AppStoreReducer.reduce(
+ initialState,
+ AppAction.UpdateSearchDialogVisibility(isVisible = true),
+ )
+
+ assertTrue(updatedState.isSearchDialogVisible)
+
+ updatedState = AppStoreReducer.reduce(
+ initialState,
+ AppAction.UpdateSearchDialogVisibility(isVisible = false),
+ )
+
+ assertFalse(updatedState.isSearchDialogVisible)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/appstate/ShoppingActionTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/appstate/ShoppingActionTest.kt
new file mode 100644
index 0000000000..30f9e708f3
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/appstate/ShoppingActionTest.kt
@@ -0,0 +1,250 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.appstate
+
+import mozilla.components.support.test.ext.joinBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppAction.ShoppingAction.HighlightsCardExpanded
+import org.mozilla.fenix.components.appstate.AppAction.ShoppingAction.InfoCardExpanded
+import org.mozilla.fenix.components.appstate.AppAction.ShoppingAction.SettingsCardExpanded
+import org.mozilla.fenix.components.appstate.shopping.ShoppingState
+
+class ShoppingActionTest {
+
+ @Test
+ fun `WHEN shopping sheet is collapsed THEN state should reflect that`() {
+ val store = AppStore(
+ initialState = AppState(
+ shoppingState = ShoppingState(
+ shoppingSheetExpanded = true,
+ ),
+ ),
+ )
+
+ store.dispatch(AppAction.ShoppingAction.ShoppingSheetStateUpdated(false)).joinBlocking()
+
+ val expected = ShoppingState(
+ shoppingSheetExpanded = false,
+ )
+
+ assertEquals(expected, store.state.shoppingState)
+ }
+
+ @Test
+ fun `WHEN shopping sheet is expanded THEN state should reflect that`() {
+ val store = AppStore()
+
+ store.dispatch(AppAction.ShoppingAction.ShoppingSheetStateUpdated(true)).joinBlocking()
+
+ val expected = ShoppingState(
+ shoppingSheetExpanded = true,
+ )
+
+ assertEquals(expected, store.state.shoppingState)
+ }
+
+ @Test
+ fun `WHEN product analysis highlights card is expanded THEN state should reflect that`() {
+ val store = AppStore(initialState = AppState(shoppingState = ShoppingState()))
+
+ store.dispatch(HighlightsCardExpanded(productPageUrl = "pdp", expanded = true))
+ .joinBlocking()
+
+ val expected = ShoppingState(
+ productCardState = mapOf(
+ "pdp" to ShoppingState.CardState(isHighlightsExpanded = true),
+ ),
+ )
+
+ assertEquals(expected, store.state.shoppingState)
+ }
+
+ @Test
+ fun `WHEN product analysis highlights card is collapsed THEN state should reflect that`() {
+ val store = AppStore(initialState = AppState(shoppingState = ShoppingState()))
+
+ store.dispatch(HighlightsCardExpanded(productPageUrl = "pdp", expanded = false))
+ .joinBlocking()
+
+ val expected = ShoppingState(
+ productCardState = mapOf(
+ "pdp" to ShoppingState.CardState(isHighlightsExpanded = false),
+ ),
+ )
+
+ assertEquals(expected, store.state.shoppingState)
+ }
+
+ @Test
+ fun `WHEN product analysis info card is expanded THEN state should reflect that`() {
+ val store = AppStore(
+ initialState = AppState(
+ shoppingState = ShoppingState(
+ productCardState = mapOf(
+ "1" to ShoppingState.CardState(
+ isHighlightsExpanded = true,
+ isSettingsExpanded = false,
+ ),
+ ),
+ ),
+ ),
+ )
+
+ store.dispatch(InfoCardExpanded(productPageUrl = "2", expanded = true))
+ .joinBlocking()
+
+ val expected = ShoppingState(
+ productCardState = mapOf(
+ "1" to ShoppingState.CardState(
+ isHighlightsExpanded = true,
+ isSettingsExpanded = false,
+ ),
+ "2" to ShoppingState.CardState(
+ isInfoExpanded = true,
+ ),
+ ),
+ )
+
+ assertEquals(expected, store.state.shoppingState)
+ }
+
+ @Test
+ fun `WHEN product analysis info card is collapsed THEN state should reflect that`() {
+ val store = AppStore(
+ initialState = AppState(
+ shoppingState = ShoppingState(
+ productCardState = mapOf(
+ "1" to ShoppingState.CardState(
+ isHighlightsExpanded = true,
+ isSettingsExpanded = false,
+ ),
+ "2" to ShoppingState.CardState(
+ isInfoExpanded = true,
+ ),
+ ),
+ ),
+ ),
+ )
+
+ store.dispatch(InfoCardExpanded(productPageUrl = "2", expanded = false))
+ .joinBlocking()
+
+ val expected = ShoppingState(
+ productCardState = mapOf(
+ "1" to ShoppingState.CardState(
+ isHighlightsExpanded = true,
+ isSettingsExpanded = false,
+ ),
+ "2" to ShoppingState.CardState(
+ isInfoExpanded = false,
+ ),
+ ),
+ )
+
+ assertEquals(expected, store.state.shoppingState)
+ }
+
+ @Test
+ fun `WHEN product analysis settings card is expanded THEN state should reflect that`() {
+ val store = AppStore(
+ initialState = AppState(
+ shoppingState = ShoppingState(
+ productCardState = mapOf(
+ "pdp" to ShoppingState.CardState(
+ isHighlightsExpanded = true,
+ isSettingsExpanded = false,
+ ),
+ ),
+ ),
+ ),
+ )
+
+ store.dispatch(SettingsCardExpanded(productPageUrl = "pdp", expanded = true))
+ .joinBlocking()
+
+ val expected = ShoppingState(
+ productCardState = mapOf(
+ "pdp" to ShoppingState.CardState(
+ isHighlightsExpanded = true,
+ isSettingsExpanded = true,
+ ),
+ ),
+ )
+
+ assertEquals(expected, store.state.shoppingState)
+ }
+
+ @Test
+ fun `WHEN product analysis settings card is collapsed THEN state should reflect that`() {
+ val store = AppStore(
+ initialState = AppState(
+ shoppingState = ShoppingState(
+ productCardState = mapOf(
+ "pdp" to ShoppingState.CardState(
+ isSettingsExpanded = true,
+ ),
+ ),
+ ),
+ ),
+ )
+
+ store.dispatch(SettingsCardExpanded(productPageUrl = "pdp", expanded = false))
+ .joinBlocking()
+
+ val expected = ShoppingState(
+ productCardState = mapOf(
+ "pdp" to ShoppingState.CardState(isSettingsExpanded = false),
+ ),
+ )
+
+ assertEquals(expected, store.state.shoppingState)
+ }
+
+ @Test
+ fun `WHEN product recommendation impression is recorded THEN state should reflect that`() {
+ val store = AppStore(
+ initialState = AppState(
+ shoppingState = ShoppingState(
+ recordedProductRecommendationImpressions = setOf(
+ ShoppingState.ProductRecommendationImpressionKey(
+ productUrl = "pdp",
+ tabId = "1",
+ aid = "aid",
+ ),
+ ),
+ ),
+ ),
+ )
+
+ store.dispatch(
+ AppAction.ShoppingAction.ProductRecommendationImpression(
+ key = ShoppingState.ProductRecommendationImpressionKey(
+ productUrl = "pdp2",
+ tabId = "2",
+ aid = "aid2",
+ ),
+ ),
+ ).joinBlocking()
+
+ val expected = ShoppingState(
+ recordedProductRecommendationImpressions = setOf(
+ ShoppingState.ProductRecommendationImpressionKey(
+ productUrl = "pdp",
+ tabId = "1",
+ aid = "aid",
+ ),
+ ShoppingState.ProductRecommendationImpressionKey(
+ productUrl = "pdp2",
+ tabId = "2",
+ aid = "aid2",
+ ),
+ ),
+ )
+
+ assertEquals(expected, store.state.shoppingState)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/appstate/TabStripActionTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/appstate/TabStripActionTest.kt
new file mode 100644
index 0000000000..42516b95dc
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/appstate/TabStripActionTest.kt
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.appstate
+
+import mozilla.components.support.test.ext.joinBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.mozilla.fenix.components.AppStore
+
+class TabStripActionTest {
+
+ @Test
+ fun `WHEN the last remaining tab that was closed was private THEN state should reflect that`() {
+ val store = AppStore(initialState = AppState())
+
+ store.dispatch(AppAction.TabStripAction.UpdateLastTabClosed(true)).joinBlocking()
+
+ val expected = AppState(wasLastTabClosedPrivate = true)
+
+ assertEquals(expected, store.state)
+ }
+
+ @Test
+ fun `WHEN the last remaining tab that was closed was not private THEN state should reflect that`() {
+ val store = AppStore(initialState = AppState())
+
+ store.dispatch(AppAction.TabStripAction.UpdateLastTabClosed(false)).joinBlocking()
+
+ val expected = AppState(wasLastTabClosedPrivate = false)
+
+ assertEquals(expected, store.state)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/bookmarks/BookmarksUseCaseTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/bookmarks/BookmarksUseCaseTest.kt
new file mode 100644
index 0000000000..e611dc8d90
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/bookmarks/BookmarksUseCaseTest.kt
@@ -0,0 +1,156 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.bookmarks
+
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import mozilla.appservices.places.BookmarkRoot
+import mozilla.components.concept.storage.BookmarkNode
+import mozilla.components.concept.storage.BookmarkNodeType
+import mozilla.components.concept.storage.BookmarksStorage
+import mozilla.components.concept.storage.HistoryStorage
+import mozilla.components.concept.storage.VisitInfo
+import mozilla.components.concept.storage.VisitType
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.mozilla.fenix.home.recentbookmarks.RecentBookmark
+import java.util.concurrent.TimeUnit
+
+class BookmarksUseCaseTest {
+
+ @Test
+ fun `WHEN adding existing bookmark THEN no new item is stored`() = runTest {
+ val bookmarksStorage = mockk<BookmarksStorage>()
+ val historyStorage = mockk<HistoryStorage>()
+ val bookmarkNode = mockk<BookmarkNode>()
+ val useCase = BookmarksUseCase(bookmarksStorage, historyStorage)
+
+ every { bookmarkNode.url }.answers { "https://mozilla.org" }
+ coEvery { bookmarksStorage.getBookmarksWithUrl(any()) }.coAnswers { listOf(bookmarkNode) }
+
+ val result = useCase.addBookmark("https://mozilla.org", "Mozilla")
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `WHEN adding bookmark THEN new item is stored`() = runTest {
+ val bookmarksStorage = mockk<BookmarksStorage>(relaxed = true)
+ val historyStorage = mockk<HistoryStorage>(relaxed = true)
+ val bookmarkNode = mockk<BookmarkNode>()
+ val useCase = BookmarksUseCase(bookmarksStorage, historyStorage)
+
+ every { bookmarkNode.url }.answers { "https://firefox.com" }
+ coEvery { bookmarksStorage.getBookmarksWithUrl(any()) }.coAnswers { listOf(bookmarkNode) }
+
+ val result = useCase.addBookmark("https://mozilla.org", "Mozilla")
+
+ assertTrue(result)
+
+ coVerify { bookmarksStorage.addItem(BookmarkRoot.Mobile.id, "https://mozilla.org", "Mozilla", null) }
+ }
+
+ @Test
+ fun `WHEN adding bookmark THEN new item is stored in folder`() = runTest {
+ val bookmarksStorage = mockk<BookmarksStorage>(relaxed = true)
+ val historyStorage = mockk<HistoryStorage>(relaxed = true)
+ val bookmarkNode = mockk<BookmarkNode>()
+ val useCase = BookmarksUseCase(bookmarksStorage, historyStorage)
+
+ every { bookmarkNode.url }.answers { "https://firefox.com" }
+ coEvery { bookmarksStorage.getBookmarksWithUrl(any()) }.coAnswers { listOf(bookmarkNode) }
+
+ val result = useCase.addBookmark("https://mozilla.org", "Mozilla", parentGuid = "parentGuid")
+
+ assertTrue(result)
+
+ coVerify { bookmarksStorage.addItem("parentGuid", "https://mozilla.org", "Mozilla", null) }
+ }
+
+ @Test
+ fun `WHEN recently saved bookmarks exist THEN retrieve the list from storage`() = runTest {
+ val bookmarksStorage = mockk<BookmarksStorage>(relaxed = true)
+ val historyStorage = mockk<HistoryStorage>(relaxed = true)
+ val useCase = BookmarksUseCase(bookmarksStorage, historyStorage)
+
+ val visitInfo = VisitInfo(
+ url = "https://www.firefox.com",
+ title = "firefox",
+ visitTime = 2,
+ visitType = VisitType.LINK,
+ previewImageUrl = "http://firefox.com/image1",
+ isRemote = false,
+ )
+ val bookmarkNode = BookmarkNode(
+ BookmarkNodeType.ITEM,
+ "987",
+ "123",
+ 2u,
+ "Firefox",
+ "https://www.firefox.com",
+ 0,
+ null,
+ )
+
+ coEvery {
+ historyStorage.getDetailedVisits(any(), any())
+ }.coAnswers { listOf(visitInfo) }
+
+ coEvery {
+ bookmarksStorage.getRecentBookmarks(
+ any(),
+ any(),
+ any(),
+ )
+ }.coAnswers { listOf(bookmarkNode) }
+
+ val result = useCase.retrieveRecentBookmarks(BookmarksUseCase.DEFAULT_BOOKMARKS_TO_RETRIEVE, 22)
+
+ assertEquals(
+ listOf(
+ RecentBookmark(
+ title = bookmarkNode.title,
+ url = bookmarkNode.url,
+ previewImageUrl = visitInfo.previewImageUrl,
+ ),
+ ),
+ result,
+ )
+
+ coVerify {
+ bookmarksStorage.getRecentBookmarks(
+ BookmarksUseCase.DEFAULT_BOOKMARKS_TO_RETRIEVE,
+ 22,
+ any(),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN there are no recently saved bookmarks THEN retrieve the empty list from storage`() = runTest {
+ val bookmarksStorage = mockk<BookmarksStorage>(relaxed = true)
+ val historyStorage = mockk<HistoryStorage>(relaxed = true)
+ val useCase = BookmarksUseCase(bookmarksStorage, historyStorage)
+
+ coEvery { bookmarksStorage.getRecentBookmarks(any(), any(), any()) }.coAnswers { listOf() }
+
+ val result = useCase.retrieveRecentBookmarks(BookmarksUseCase.DEFAULT_BOOKMARKS_TO_RETRIEVE)
+
+ assertEquals(listOf<BookmarkNode>(), result)
+
+ coVerify {
+ bookmarksStorage.getRecentBookmarks(
+ BookmarksUseCase.DEFAULT_BOOKMARKS_TO_RETRIEVE,
+ TimeUnit.DAYS.toMillis(BookmarksUseCase.DEFAULT_BOOKMARKS_DAYS_AGE_TO_RETRIEVE),
+ any(),
+ )
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/history/PagedHistoryProviderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/history/PagedHistoryProviderTest.kt
new file mode 100644
index 0000000000..cf35548515
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/history/PagedHistoryProviderTest.kt
@@ -0,0 +1,496 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.history
+
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.storage.sync.PlacesHistoryStorage
+import mozilla.components.concept.storage.DocumentType
+import mozilla.components.concept.storage.HistoryMetadata
+import mozilla.components.concept.storage.HistoryMetadataKey
+import mozilla.components.concept.storage.VisitInfo
+import mozilla.components.concept.storage.VisitType
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.utils.Settings
+
+class PagedHistoryProviderTest {
+
+ private lateinit var storage: PlacesHistoryStorage
+
+ @Before
+ fun setup() {
+ storage = mockk()
+ Settings.SEARCH_GROUP_MINIMUM_SITES = 1
+ }
+
+ @Test
+ fun `getHistory uses getVisitsPaginated`() = runTest {
+ val provider = DefaultPagedHistoryProvider(
+ historyStorage = storage,
+ )
+
+ val visitInfo1 = VisitInfo(
+ url = "http://www.mozilla.com",
+ title = "mozilla",
+ visitTime = 5,
+ visitType = VisitType.LINK,
+ previewImageUrl = null,
+ isRemote = false,
+ )
+ val visitInfo2 = VisitInfo(
+ url = "http://www.firefox.com",
+ title = "firefox",
+ visitTime = 2,
+ visitType = VisitType.LINK,
+ previewImageUrl = null,
+ isRemote = false,
+ )
+ val visitInfo3 = VisitInfo(
+ url = "http://www.wikipedia.com",
+ title = "wikipedia",
+ visitTime = 1,
+ visitType = VisitType.LINK,
+ previewImageUrl = null,
+ isRemote = false,
+ )
+ val historyMetadataKey1 = HistoryMetadataKey("http://www.mozilla.com", "mozilla", null)
+ val historyEntry1 = HistoryMetadata(
+ key = historyMetadataKey1,
+ title = "mozilla",
+ createdAt = 150000000, // a large amount to fall outside of the history page.
+ updatedAt = 10,
+ totalViewTime = 10,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null,
+ )
+ val historyMetadataKey2 = HistoryMetadataKey("http://www.firefox.com", "mozilla", null)
+ val historyEntry2 = HistoryMetadata(
+ key = historyMetadataKey2,
+ title = "firefox",
+ createdAt = 2,
+ updatedAt = 11,
+ totalViewTime = 20,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null,
+ )
+
+ // Adding a third entry with same url to test de-duping
+ val historyMetadataKey3 = HistoryMetadataKey("http://www.firefox.com", "mozilla", null)
+ val historyEntry3 = HistoryMetadata(
+ key = historyMetadataKey3,
+ title = "firefox",
+ createdAt = 3,
+ updatedAt = 12,
+ totalViewTime = 30,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null,
+ )
+
+ coEvery { storage.getVisitsPaginated(any(), any(), any()) } returns listOf(visitInfo1, visitInfo2, visitInfo3)
+ coEvery { storage.getDetailedVisits(any(), any(), any()) } returns emptyList()
+ coEvery { storage.getHistoryMetadataSince(any()) } returns listOf(historyEntry1, historyEntry2, historyEntry3)
+
+ val actualResults: List<HistoryDB> = provider.getHistory(10, 5)
+
+ coVerify {
+ storage.getVisitsPaginated(
+ offset = 10L,
+ count = 5,
+ excludeTypes = listOf(
+ VisitType.DOWNLOAD,
+ VisitType.REDIRECT_PERMANENT,
+ VisitType.REDIRECT_TEMPORARY,
+ VisitType.RELOAD,
+ VisitType.EMBED,
+ VisitType.FRAMED_LINK,
+ ),
+ )
+ }
+
+ val results = listOf(
+ HistoryDB.Group(
+ title = historyEntry1.key.searchTerm!!,
+ visitedAt = historyEntry1.createdAt,
+ // Results are de-duped by URL and sorted descending by createdAt/visitedAt
+ items = listOf(
+ HistoryDB.Metadata(
+ title = historyEntry1.title!!,
+ url = historyEntry1.key.url,
+ visitedAt = historyEntry1.createdAt,
+ totalViewTime = historyEntry1.totalViewTime,
+ historyMetadataKey = historyMetadataKey1,
+ ),
+ HistoryDB.Metadata(
+ title = historyEntry3.title!!,
+ url = historyEntry3.key.url,
+ visitedAt = historyEntry3.createdAt,
+ totalViewTime = historyEntry3.totalViewTime,
+ historyMetadataKey = historyMetadataKey2,
+ ),
+ ),
+ ),
+ HistoryDB.Regular(
+ title = visitInfo3.title!!,
+ url = visitInfo3.url,
+ visitedAt = visitInfo3.visitTime,
+ ),
+ )
+ assertEquals(results, actualResults)
+ }
+
+ @Test
+ fun `history metadata matching lower bound`() = runTest {
+ val provider = DefaultPagedHistoryProvider(
+ historyStorage = storage,
+ )
+ // Oldest history visit on the page is 15 seconds (buffer time) newer than matching
+ // metadata record.
+ val visitInfo1 = VisitInfo(
+ url = "http://www.mozilla.com",
+ title = "mozilla",
+ visitTime = 25000,
+ visitType = VisitType.LINK,
+ previewImageUrl = null,
+ isRemote = false,
+ )
+
+ val historyMetadataKey1 = HistoryMetadataKey("http://www.mozilla.com", "mozilla", null)
+ val historyEntry1 = HistoryMetadata(
+ key = historyMetadataKey1,
+ title = "mozilla",
+ createdAt = 10000,
+ updatedAt = 10,
+ totalViewTime = 10,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null,
+ )
+
+ coEvery { storage.getVisitsPaginated(any(), any(), any()) } returns listOf(visitInfo1)
+ coEvery { storage.getDetailedVisits(any(), any(), any()) } returns emptyList()
+ coEvery { storage.getHistoryMetadataSince(any()) } returns listOf(historyEntry1)
+
+ val actualResults: List<HistoryDB> = provider.getHistory(0, 5)
+
+ coVerify {
+ storage.getVisitsPaginated(
+ offset = 0L,
+ count = 5,
+ excludeTypes = listOf(
+ VisitType.DOWNLOAD,
+ VisitType.REDIRECT_PERMANENT,
+ VisitType.REDIRECT_TEMPORARY,
+ VisitType.RELOAD,
+ VisitType.EMBED,
+ VisitType.FRAMED_LINK,
+ ),
+ )
+ }
+
+ val results = listOf(
+ HistoryDB.Group(
+ title = historyEntry1.key.searchTerm!!,
+ visitedAt = historyEntry1.createdAt,
+ // Results are de-duped by URL and sorted descending by createdAt/visitedAt
+ items = listOf(
+ HistoryDB.Metadata(
+ title = historyEntry1.title!!,
+ url = historyEntry1.key.url,
+ visitedAt = historyEntry1.createdAt,
+ totalViewTime = historyEntry1.totalViewTime,
+ historyMetadataKey = historyMetadataKey1,
+ ),
+ ),
+ ),
+ )
+
+ assertEquals(results, actualResults)
+ }
+
+ @Test
+ fun `history metadata matching upper bound`() = runTest {
+ val provider = DefaultPagedHistoryProvider(
+ historyStorage = storage,
+ )
+ // Newest history visit on the page is 15 seconds (buffer time) older than matching
+ // metadata record.
+ val visitInfo1 = VisitInfo(
+ url = "http://www.mozilla.com",
+ title = "mozilla",
+ visitTime = 10000,
+ visitType = VisitType.LINK,
+ previewImageUrl = null,
+ isRemote = false,
+ )
+
+ val historyMetadataKey1 = HistoryMetadataKey("http://www.mozilla.com", "mozilla", null)
+ val historyEntry1 = HistoryMetadata(
+ key = historyMetadataKey1,
+ title = "mozilla",
+ createdAt = 25000,
+ updatedAt = 10,
+ totalViewTime = 10,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null,
+ )
+
+ coEvery { storage.getVisitsPaginated(any(), any(), any()) } returns listOf(visitInfo1)
+ coEvery { storage.getDetailedVisits(any(), any(), any()) } returns emptyList()
+ coEvery { storage.getHistoryMetadataSince(any()) } returns listOf(historyEntry1)
+
+ val actualResults: List<HistoryDB> = provider.getHistory(0, 5)
+
+ coVerify {
+ storage.getVisitsPaginated(
+ offset = 0L,
+ count = 5,
+ excludeTypes = listOf(
+ VisitType.DOWNLOAD,
+ VisitType.REDIRECT_PERMANENT,
+ VisitType.REDIRECT_TEMPORARY,
+ VisitType.RELOAD,
+ VisitType.EMBED,
+ VisitType.FRAMED_LINK,
+ ),
+ )
+ }
+
+ val results = listOf(
+ HistoryDB.Group(
+ title = historyEntry1.key.searchTerm!!,
+ visitedAt = historyEntry1.createdAt,
+ // Results are de-duped by URL and sorted descending by createdAt/visitedAt
+ items = listOf(
+ HistoryDB.Metadata(
+ title = historyEntry1.title!!,
+ url = historyEntry1.key.url,
+ visitedAt = historyEntry1.createdAt,
+ totalViewTime = historyEntry1.totalViewTime,
+ historyMetadataKey = historyMetadataKey1,
+ ),
+ ),
+ ),
+ )
+
+ assertEquals(results, actualResults)
+ }
+
+ @Test
+ fun `redirects are filtered out from history metadata groups`() = runTest {
+ val provider = DefaultPagedHistoryProvider(
+ historyStorage = storage,
+ )
+
+ val visitInfo1 = VisitInfo(
+ url = "http://www.mozilla.com",
+ title = "mozilla",
+ visitTime = 5,
+ visitType = VisitType.LINK,
+ previewImageUrl = null,
+ isRemote = false,
+ )
+ val visitInfo2 = VisitInfo(
+ url = "http://www.firefox.com",
+ title = "firefox",
+ visitTime = 2,
+ visitType = VisitType.LINK,
+ previewImageUrl = null,
+ isRemote = false,
+ )
+ val visitInfo3 = VisitInfo(
+ url = "http://www.google.com/link?url=http://www.firefox.com",
+ title = "",
+ visitTime = 1,
+ visitType = VisitType.REDIRECT_TEMPORARY,
+ previewImageUrl = null,
+ isRemote = false,
+ )
+ val visitInfo4 = VisitInfo(
+ url = "http://mozilla.com",
+ title = "",
+ visitTime = 1,
+ visitType = VisitType.REDIRECT_PERMANENT,
+ previewImageUrl = null,
+ isRemote = false,
+ )
+
+ val historyMetadataKey1 = HistoryMetadataKey("http://www.mozilla.com", "mozilla", null)
+ val historyEntry1 = HistoryMetadata(
+ key = historyMetadataKey1,
+ title = "mozilla",
+ createdAt = 1,
+ updatedAt = 10,
+ totalViewTime = 10,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null,
+ )
+ val historyMetadataKey2 = HistoryMetadataKey("http://www.firefox.com", "mozilla", null)
+ val historyEntry2 = HistoryMetadata(
+ key = historyMetadataKey2,
+ title = "firefox",
+ createdAt = 2,
+ updatedAt = 11,
+ totalViewTime = 20,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null,
+ )
+ val historyMetadataKey3 = HistoryMetadataKey("http://www.google.com/link?url=http://www.firefox.com", "mozilla", null)
+ val historyEntry3 = HistoryMetadata(
+ key = historyMetadataKey3,
+ title = "",
+ createdAt = 2,
+ updatedAt = 11,
+ totalViewTime = 0,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null,
+ )
+ val historyMetadataKey4 = HistoryMetadataKey("http://mozilla.com", "mozilla", null)
+ val historyEntry4 = HistoryMetadata(
+ key = historyMetadataKey4,
+ title = "",
+ createdAt = 2,
+ updatedAt = 11,
+ totalViewTime = 0,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null,
+ )
+
+ // Normal visits.
+ coEvery {
+ storage.getVisitsPaginated(
+ any(),
+ any(),
+ eq(
+ listOf(
+ VisitType.DOWNLOAD,
+ VisitType.REDIRECT_PERMANENT,
+ VisitType.REDIRECT_TEMPORARY,
+ VisitType.RELOAD,
+ VisitType.EMBED,
+ VisitType.FRAMED_LINK,
+ ),
+ ),
+ )
+ } returns listOf(visitInfo1, visitInfo2)
+ // Redirects.
+ coEvery {
+ storage.getDetailedVisits(
+ any(),
+ any(),
+ eq(
+ VisitType.values().filterNot {
+ it == VisitType.REDIRECT_PERMANENT || it == VisitType.REDIRECT_TEMPORARY
+ },
+ ),
+ )
+ } returns listOf(visitInfo3, visitInfo4)
+
+ coEvery { storage.getHistoryMetadataSince(any()) } returns listOf(historyEntry1, historyEntry2, historyEntry3, historyEntry4)
+
+ val actualResults: List<HistoryDB> = provider.getHistory(10, 5)
+
+ coVerify {
+ storage.getVisitsPaginated(
+ offset = 10L,
+ count = 5,
+ excludeTypes = listOf(
+ VisitType.DOWNLOAD,
+ VisitType.REDIRECT_PERMANENT,
+ VisitType.REDIRECT_TEMPORARY,
+ VisitType.RELOAD,
+ VisitType.EMBED,
+ VisitType.FRAMED_LINK,
+ ),
+ )
+ }
+
+ val results = listOf(
+ HistoryDB.Group(
+ title = historyEntry2.key.searchTerm!!,
+ visitedAt = historyEntry2.createdAt,
+ items = listOf(
+ HistoryDB.Metadata(
+ title = historyEntry2.title!!,
+ url = historyEntry2.key.url,
+ visitedAt = historyEntry2.createdAt,
+ totalViewTime = historyEntry2.totalViewTime,
+ historyMetadataKey = historyMetadataKey2,
+ ),
+ HistoryDB.Metadata(
+ title = historyEntry1.title!!,
+ url = historyEntry1.key.url,
+ visitedAt = historyEntry1.createdAt,
+ totalViewTime = historyEntry1.totalViewTime,
+ historyMetadataKey = historyMetadataKey1,
+ ),
+ ),
+ ),
+ )
+ assertEquals(results, actualResults)
+ }
+
+ @Test
+ fun `WHEN removeConsecutiveDuplicates is called THEN all consecutive duplicates must be removed`() {
+ val results = listOf(
+ HistoryDB.Group(
+ title = "Group 1",
+ visitedAt = 0,
+ items = emptyList(),
+ ),
+ HistoryDB.Regular(
+ title = "No duplicate item",
+ url = "url",
+ visitedAt = 0,
+ ),
+ HistoryDB.Regular(
+ title = "Duplicate item 1",
+ url = "url",
+ visitedAt = 0,
+ ),
+ HistoryDB.Regular(
+ title = "Duplicate item 2",
+ url = "url",
+ visitedAt = 0,
+ ),
+ HistoryDB.Group(
+ title = "Group 5",
+ visitedAt = 0,
+ items = emptyList(),
+ ),
+ HistoryDB.Regular(
+ title = "No duplicate item",
+ url = "url",
+ visitedAt = 0,
+ ),
+ ).removeConsecutiveDuplicates()
+
+ val expectedList = listOf(
+ HistoryDB.Group(
+ title = "Group 1",
+ visitedAt = 0,
+ items = emptyList(),
+ ),
+ HistoryDB.Regular(
+ title = "No duplicate item",
+ url = "url",
+ visitedAt = 0,
+ ),
+ HistoryDB.Group(
+ title = "Group 5",
+ visitedAt = 0,
+ items = emptyList(),
+ ),
+ HistoryDB.Regular(
+ title = "No duplicate item",
+ url = "url",
+ visitedAt = 0,
+ ),
+ )
+ assertEquals(expectedList, results)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/menu/MenuNavigationMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/menu/MenuNavigationMiddlewareTest.kt
new file mode 100644
index 0000000000..5b6ddb184a
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/menu/MenuNavigationMiddlewareTest.kt
@@ -0,0 +1,123 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.menu
+
+import androidx.navigation.NavController
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import mozilla.appservices.places.BookmarkRoot
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.R
+import org.mozilla.fenix.components.menu.middleware.MenuNavigationMiddleware
+import org.mozilla.fenix.components.menu.store.MenuAction
+import org.mozilla.fenix.components.menu.store.MenuState
+import org.mozilla.fenix.components.menu.store.MenuStore
+import org.mozilla.fenix.ext.nav
+import org.mozilla.fenix.settings.SupportUtils.SumoTopic
+
+class MenuNavigationMiddlewareTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val scope = coroutinesTestRule.scope
+
+ private val navController: NavController = mockk(relaxed = true)
+
+ @Test
+ fun `WHEN navigate to settings action is dispatched THEN navigate to settings`() = runTest {
+ val store = createStore()
+ store.dispatch(MenuAction.Navigate.Settings).join()
+
+ verify {
+ navController.nav(
+ R.id.menuDialogFragment,
+ MenuDialogFragmentDirections.actionGlobalSettingsFragment(),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN navigate to help action is dispatched THEN navigate to SUMO Help topic`() = runTest {
+ var topic: SumoTopic? = null
+ val store = createStore(
+ openSumoTopic = {
+ topic = it
+ },
+ )
+
+ store.dispatch(MenuAction.Navigate.Help).join()
+
+ assertEquals(SumoTopic.HELP, topic)
+ }
+
+ @Test
+ fun `WHEN navigate to bookmarks action is dispatched THEN navigate to bookmarks`() = runTest {
+ val store = createStore()
+ store.dispatch(MenuAction.Navigate.Settings).join()
+
+ verify {
+ navController.nav(
+ R.id.menuDialogFragment,
+ MenuDialogFragmentDirections.actionGlobalBookmarkFragment(BookmarkRoot.Mobile.id),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN navigate to history action is dispatched THEN navigate to history`() = runTest {
+ val store = createStore()
+ store.dispatch(MenuAction.Navigate.Settings).join()
+
+ verify {
+ navController.nav(
+ R.id.menuDialogFragment,
+ MenuDialogFragmentDirections.actionGlobalHistoryFragment(),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN navigate to downloads action is dispatched THEN navigate to downloads`() = runTest {
+ val store = createStore()
+ store.dispatch(MenuAction.Navigate.Settings).join()
+
+ verify {
+ navController.nav(
+ R.id.menuDialogFragment,
+ MenuDialogFragmentDirections.actionGlobalDownloadsFragment(),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN navigate to passwords action is dispatched THEN navigate to passwords`() = runTest {
+ val store = createStore()
+ store.dispatch(MenuAction.Navigate.Settings).join()
+
+ verify {
+ navController.nav(
+ R.id.menuDialogFragment,
+ MenuDialogFragmentDirections.actionGlobalSavedLoginsAuthFragment(),
+ )
+ }
+ }
+
+ private fun createStore(
+ openSumoTopic: (topic: SumoTopic) -> Unit = {},
+ ) = MenuStore(
+ initialState = MenuState(),
+ middleware = listOf(
+ MenuNavigationMiddleware(
+ navController = navController,
+ openSumoTopic = openSumoTopic,
+ scope = scope,
+ ),
+ ),
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/menu/MenuStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/menu/MenuStoreTest.kt
new file mode 100644
index 0000000000..6c6bd73b50
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/menu/MenuStoreTest.kt
@@ -0,0 +1,37 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.menu
+
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.components.menu.store.MenuAction
+import org.mozilla.fenix.components.menu.store.MenuState
+import org.mozilla.fenix.components.menu.store.MenuStore
+
+class MenuStoreTest {
+
+ private lateinit var state: MenuState
+ private lateinit var store: MenuStore
+
+ @Before
+ fun setup() {
+ state = MenuState()
+ store = MenuStore(initialState = state)
+ }
+
+ @Test
+ fun `WHEN update bookmarked action is dispatched THEN bookmarked state is updated`() = runTest {
+ assertFalse(store.state.isBookmarked)
+
+ store.dispatch(MenuAction.UpdateBookmarked(isBookmarked = true)).join()
+ assertTrue(store.state.isBookmarked)
+
+ store.dispatch(MenuAction.UpdateBookmarked(isBookmarked = false)).join()
+ assertFalse(store.state.isBookmarked)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/ActivationPingTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/ActivationPingTest.kt
new file mode 100644
index 0000000000..d038c3165d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/ActivationPingTest.kt
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.metrics
+
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import org.junit.Test
+
+internal class ActivationPingTest {
+ @Test
+ fun `checkAndSend() triggers the ping if it wasn't marked as triggered`() {
+ val mockAp = spyk(ActivationPing(mockk()), recordPrivateCalls = true)
+ every { mockAp.wasAlreadyTriggered() } returns false
+ every { mockAp.markAsTriggered() } just Runs
+
+ mockAp.checkAndSend()
+
+ verify(exactly = 1) { mockAp.triggerPing() }
+ // Marking the ping as triggered happens in a co-routine off the main thread,
+ // so wait a bit for it.
+ verify(timeout = 5000, exactly = 1) { mockAp.markAsTriggered() }
+ }
+
+ @Test
+ fun `checkAndSend() doesn't trigger the ping again if it was marked as triggered`() {
+ val mockAp = spyk(ActivationPing(mockk()), recordPrivateCalls = true)
+ every { mockAp.wasAlreadyTriggered() } returns true
+
+ mockAp.checkAndSend()
+
+ verify(exactly = 0) { mockAp.triggerPing() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/BreadcrumbRecorderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/BreadcrumbRecorderTest.kt
new file mode 100644
index 0000000000..769f78457a
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/BreadcrumbRecorderTest.kt
@@ -0,0 +1,80 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.metrics
+
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleRegistry
+import androidx.navigation.NavController
+import androidx.navigation.NavDestination
+import io.mockk.Called
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.concept.base.crash.Breadcrumb
+import mozilla.components.lib.crash.Crash
+import mozilla.components.lib.crash.CrashReporter
+import mozilla.components.lib.crash.service.CrashReporterService
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class BreadcrumbRecorderTest {
+
+ @Test
+ fun `sets listener on create and destroy`() {
+ val navController: NavController = mockk(relaxUnitFun = true)
+
+ val lifecycle = LifecycleRegistry(mockk())
+ val breadCrumbRecorder = BreadcrumbsRecorder(mockk(), navController) { "test" }
+
+ lifecycle.addObserver(breadCrumbRecorder)
+ verify { navController wasNot Called }
+
+ lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
+ verify { navController.addOnDestinationChangedListener(breadCrumbRecorder) }
+
+ lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
+ verify { navController.removeOnDestinationChangedListener(breadCrumbRecorder) }
+ }
+
+ @Test
+ fun `ensure crash reporter recordCrashBreadcrumb is called`() {
+ val service = object : CrashReporterService {
+ override val id: String = "test"
+ override val name: String = "Test"
+ override fun createCrashReportUrl(identifier: String): String? = null
+ override fun report(throwable: Throwable, breadcrumbs: ArrayList<Breadcrumb>): String? = ""
+ override fun report(crash: Crash.NativeCodeCrash): String? = ""
+ override fun report(crash: Crash.UncaughtExceptionCrash): String? = ""
+ }
+
+ val reporter = spyk(
+ CrashReporter(
+ context = mockk(),
+ services = listOf(service),
+ shouldPrompt = CrashReporter.Prompt.NEVER,
+ notificationsDelegate = mockk(),
+ ),
+ )
+
+ val navController: NavController = mockk()
+ val navDestination: NavDestination = mockk()
+
+ val breadCrumbRecorder = BreadcrumbsRecorder(reporter, navController) { "test" }
+ breadCrumbRecorder.onDestinationChanged(navController, navDestination, null)
+
+ verify {
+ reporter.recordCrashBreadcrumb(
+ withArg {
+ assertEquals("test", it.message)
+ assertEquals("DestinationChanged", it.category)
+ assertEquals(Breadcrumb.Level.INFO, it.level)
+ },
+ )
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/DefaultMetricsStorageTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/DefaultMetricsStorageTest.kt
new file mode 100644
index 0000000000..d07620b85b
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/DefaultMetricsStorageTest.kt
@@ -0,0 +1,505 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.metrics
+
+import android.app.Activity
+import android.app.Application
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.runTest
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.utils.Settings
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Locale
+
+class DefaultMetricsStorageTest {
+
+ private val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.US)
+ private val calendarStart = Calendar.getInstance(Locale.US)
+ private val dayMillis: Long = 1000 * 60 * 60 * 24
+ private val usageThresholdMillis: Long = 340 * 1000
+
+ private var checkDefaultBrowser = false
+ private val doCheckDefaultBrowser = { checkDefaultBrowser }
+ private var shouldSendGenerally = true
+ private val doShouldSendGenerally = { shouldSendGenerally }
+ private var installTime = 0L
+ private val doGetInstallTime = { installTime }
+
+ private val settings = mockk<Settings>()
+
+ private val dispatcher = StandardTestDispatcher()
+
+ private lateinit var storage: DefaultMetricsStorage
+
+ @Before
+ fun setup() {
+ checkDefaultBrowser = false
+ shouldSendGenerally = true
+ installTime = System.currentTimeMillis()
+
+ every { settings.firstWeekDaysOfUseGrowthData } returns setOf()
+ every { settings.firstWeekDaysOfUseGrowthData = any() } returns Unit
+
+ storage = DefaultMetricsStorage(mockk(), settings, doCheckDefaultBrowser, doShouldSendGenerally, doGetInstallTime, dispatcher)
+ }
+
+ @Test
+ fun `GIVEN that events should not be generally sent WHEN event would be tracked THEN it is not`() = runTest(dispatcher) {
+ shouldSendGenerally = false
+ checkDefaultBrowser = true
+ every { settings.setAsDefaultGrowthSent } returns false
+
+ val result = storage.shouldTrack(Event.GrowthData.SetAsDefault)
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `GIVEN set as default has not been sent and app is not default WHEN checked for sending THEN will not be sent`() = runTest(dispatcher) {
+ every { settings.setAsDefaultGrowthSent } returns false
+ checkDefaultBrowser = false
+
+ val result = storage.shouldTrack(Event.GrowthData.SetAsDefault)
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `GIVEN set as default has not been sent and app is default WHEN checked for sending THEN will be sent`() = runTest(dispatcher) {
+ every { settings.setAsDefaultGrowthSent } returns false
+ checkDefaultBrowser = true
+
+ val result = storage.shouldTrack(Event.GrowthData.SetAsDefault)
+
+ assertTrue(result)
+ }
+
+ @Test
+ fun `GIVEN set as default has been sent and app is default WHEN checked for sending THEN will be not sent`() = runTest(dispatcher) {
+ every { settings.setAsDefaultGrowthSent } returns true
+ checkDefaultBrowser = true
+
+ val result = storage.shouldTrack(Event.GrowthData.SetAsDefault)
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `WHEN set as default updated THEN settings will be updated accordingly`() = runTest(dispatcher) {
+ val updateSlot = slot<Boolean>()
+ every { settings.setAsDefaultGrowthSent = capture(updateSlot) } returns Unit
+
+ storage.updateSentState(Event.GrowthData.SetAsDefault)
+
+ assertTrue(updateSlot.captured)
+ }
+
+ @Test
+ fun `GIVEN that app has been used for less than 3 days in a row WHEN checked for first week activity THEN event will not be sent`() = runTest(dispatcher) {
+ val tomorrow = calendarStart.createNextDay()
+ every { settings.firstWeekDaysOfUseGrowthData = any() } returns Unit
+ every { settings.firstWeekDaysOfUseGrowthData } returns setOf(calendarStart, tomorrow).toStrings()
+ every { settings.firstWeekSeriesGrowthSent } returns false
+
+ val result = storage.shouldTrack(Event.GrowthData.FirstWeekSeriesActivity)
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `GIVEN that app has only been used for 3 days in a row WHEN checked for first week activity THEN event will be sent`() = runTest(dispatcher) {
+ val tomorrow = calendarStart.createNextDay()
+ val thirdDay = tomorrow.createNextDay()
+ every { settings.firstWeekDaysOfUseGrowthData } returns setOf(calendarStart, tomorrow, thirdDay).toStrings()
+ every { settings.firstWeekSeriesGrowthSent } returns false
+
+ val result = storage.shouldTrack(Event.GrowthData.FirstWeekSeriesActivity)
+
+ assertTrue(result)
+ }
+
+ @Test
+ fun `GIVEN that app has been used for 3 days but not consecutively WHEN checked for first week activity THEN event will be not sent`() = runTest(dispatcher) {
+ val tomorrow = calendarStart.createNextDay()
+ val fourDaysFromNow = tomorrow.createNextDay().createNextDay()
+ every { settings.firstWeekDaysOfUseGrowthData = any() } returns Unit
+ every { settings.firstWeekDaysOfUseGrowthData } returns setOf(calendarStart, tomorrow, fourDaysFromNow).toStrings()
+ every { settings.firstWeekSeriesGrowthSent } returns false
+
+ val result = storage.shouldTrack(Event.GrowthData.FirstWeekSeriesActivity)
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `GIVEN that app has been used for 3 days consecutively but not within first week WHEN checked for first week activity THEN event will be not sent`() = runTest(dispatcher) {
+ val tomorrow = calendarStart.createNextDay()
+ val thirdDay = tomorrow.createNextDay()
+ val installTime9DaysEarlier = calendarStart.timeInMillis - (dayMillis * 9)
+ every { settings.firstWeekDaysOfUseGrowthData } returns setOf(calendarStart, tomorrow, thirdDay).toStrings()
+ every { settings.firstWeekSeriesGrowthSent } returns false
+ installTime = installTime9DaysEarlier
+
+ val result = storage.shouldTrack(Event.GrowthData.FirstWeekSeriesActivity)
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `GIVEN that first week activity has already been sent WHEN checked for first week activity THEN event will be not sent`() = runTest(dispatcher) {
+ val tomorrow = calendarStart.createNextDay()
+ val thirdDay = tomorrow.createNextDay()
+ every { settings.firstWeekDaysOfUseGrowthData } returns setOf(calendarStart, tomorrow, thirdDay).toStrings()
+ every { settings.firstWeekSeriesGrowthSent } returns true
+
+ val result = storage.shouldTrack(Event.GrowthData.FirstWeekSeriesActivity)
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `GIVEN that first week activity is not sent WHEN checked to send THEN current day is added to rolling days`() = runTest(dispatcher) {
+ val captureRolling = slot<Set<String>>()
+ val previousDay = calendarStart.createPreviousDay()
+ every { settings.firstWeekDaysOfUseGrowthData } returns setOf(previousDay).toStrings()
+ every { settings.firstWeekDaysOfUseGrowthData = capture(captureRolling) } returns Unit
+ every { settings.firstWeekSeriesGrowthSent } returns false
+
+ storage.shouldTrack(Event.GrowthData.FirstWeekSeriesActivity)
+
+ assertTrue(captureRolling.captured.contains(formatter.format(calendarStart.time)))
+ }
+
+ @Test
+ fun `WHEN first week activity state updated THEN settings updated accordingly`() = runTest(dispatcher) {
+ val captureSent = slot<Boolean>()
+ every { settings.firstWeekSeriesGrowthSent } returns false
+ every { settings.firstWeekSeriesGrowthSent = capture(captureSent) } returns Unit
+
+ storage.updateSentState(Event.GrowthData.FirstWeekSeriesActivity)
+
+ assertTrue(captureSent.captured)
+ }
+
+ @Test
+ fun `GIVEN not yet in recording window WHEN checking to track THEN days of use still updated`() = runTest(dispatcher) {
+ shouldSendGenerally = false
+ val captureSlot = slot<Set<String>>()
+ every { settings.firstWeekDaysOfUseGrowthData } returns setOf()
+ every { settings.firstWeekDaysOfUseGrowthData = capture(captureSlot) } returns Unit
+
+ storage.shouldTrack(Event.GrowthData.FirstWeekSeriesActivity)
+
+ assertTrue(captureSlot.captured.isNotEmpty())
+ }
+
+ @Test
+ fun `GIVEN outside first week after install WHEN checking to track THEN days of use is not updated`() = runTest(dispatcher) {
+ val captureSlot = slot<Set<String>>()
+ every { settings.firstWeekDaysOfUseGrowthData } returns setOf()
+ every { settings.firstWeekDaysOfUseGrowthData = capture(captureSlot) } returns Unit
+ installTime = calendarStart.timeInMillis - (dayMillis * 9)
+
+ storage.shouldTrack(Event.GrowthData.FirstWeekSeriesActivity)
+
+ assertFalse(captureSlot.isCaptured)
+ }
+
+ @Test
+ fun `GIVEN serp ad clicked event already sent WHEN checking to track serp ad clicked THEN event will not be sent`() = runTest(dispatcher) {
+ every { settings.adClickGrowthSent } returns true
+
+ val result = storage.shouldTrack(Event.GrowthData.SerpAdClicked)
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `GIVEN serp ad clicked event not sent WHEN checking to track serp ad clicked THEN event will be sent`() = runTest(dispatcher) {
+ every { settings.adClickGrowthSent } returns false
+
+ val result = storage.shouldTrack(Event.GrowthData.SerpAdClicked)
+
+ assertTrue(result)
+ }
+
+ @Test
+ fun `GIVEN usage time has not passed threshold and has not been sent WHEN checking to track THEN event will not be sent`() = runTest(dispatcher) {
+ every { settings.usageTimeGrowthData } returns usageThresholdMillis - 1
+ every { settings.usageTimeGrowthSent } returns false
+
+ val result = storage.shouldTrack(Event.GrowthData.UsageThreshold)
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `GIVEN usage time has passed threshold and has not been sent WHEN checking to track THEN event will be sent`() = runTest(dispatcher) {
+ every { settings.usageTimeGrowthData } returns usageThresholdMillis + 1
+ every { settings.usageTimeGrowthSent } returns false
+
+ val result = storage.shouldTrack(Event.GrowthData.UsageThreshold)
+
+ assertTrue(result)
+ }
+
+ @Test
+ fun `GIVEN usage time growth has not been sent and within first day WHEN registering as usage recorder THEN will be registered`() {
+ val application = mockk<Application>()
+ every { settings.usageTimeGrowthSent } returns false
+ every { application.registerActivityLifecycleCallbacks(any()) } returns Unit
+
+ storage.tryRegisterAsUsageRecorder(application)
+
+ verify { application.registerActivityLifecycleCallbacks(any()) }
+ }
+
+ @Test
+ fun `GIVEN usage time growth has not been sent and not within first day WHEN registering as usage recorder THEN will not be registered`() {
+ val application = mockk<Application>()
+ installTime = System.currentTimeMillis() - dayMillis * 2
+ every { settings.usageTimeGrowthSent } returns false
+
+ storage.tryRegisterAsUsageRecorder(application)
+
+ verify(exactly = 0) { application.registerActivityLifecycleCallbacks(any()) }
+ }
+
+ @Test
+ fun `GIVEN usage time growth has been sent WHEN registering as usage recorder THEN will not be registered`() {
+ val application = mockk<Application>()
+ every { settings.usageTimeGrowthSent } returns true
+
+ storage.tryRegisterAsUsageRecorder(application)
+
+ verify(exactly = 0) { application.registerActivityLifecycleCallbacks(any()) }
+ }
+
+ @Test
+ fun `WHEN updating usage state THEN storage will be delegated to settings`() {
+ val initial = 10L
+ val update = 15L
+ val slot = slot<Long>()
+ every { settings.usageTimeGrowthData } returns initial
+ every { settings.usageTimeGrowthData = capture(slot) } returns Unit
+
+ storage.updateUsageState(update)
+
+ assertEquals(slot.captured, initial + update)
+ }
+
+ @Test
+ fun `WHEN usage recorder receives onResume and onPause callbacks THEN it will store usage length`() {
+ val storage = mockk<MetricsStorage>()
+ val activity = mockk<Activity>()
+ val slot = slot<Long>()
+ every { storage.updateUsageState(capture(slot)) } returns Unit
+ every { activity.componentName } returns mock()
+
+ val usageRecorder = DefaultMetricsStorage.UsageRecorder(storage)
+ val startTime = System.currentTimeMillis()
+
+ usageRecorder.onActivityResumed(activity)
+ usageRecorder.onActivityPaused(activity)
+ val stopTime = System.currentTimeMillis()
+
+ assertTrue(slot.captured < stopTime - startTime)
+ }
+
+ @Test
+ fun `GIVEN that it has been less than 24 hours since last resumed sent WHEN checked for sending THEN will not be sent`() = runTest(dispatcher) {
+ val currentTime = System.currentTimeMillis()
+ every { settings.resumeGrowthLastSent } returns currentTime
+
+ val result = storage.shouldTrack(Event.GrowthData.FirstAppOpenForDay)
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `GIVEN that it has been more than 24 hours since last resumed sent WHEN checked for sending THEN will be sent`() = runTest(dispatcher) {
+ val currentTime = System.currentTimeMillis()
+ installTime = currentTime - (dayMillis + 1)
+ every { settings.resumeGrowthLastSent } returns currentTime - 1000 * 60 * 60 * 24 * 2
+
+ val result = storage.shouldTrack(Event.GrowthData.FirstAppOpenForDay)
+
+ assertTrue(result)
+ }
+
+ @Test
+ fun `WHEN last resumed state updated THEN settings updated accordingly`() = runTest(dispatcher) {
+ val updateSlot = slot<Long>()
+ every { settings.resumeGrowthLastSent } returns 0
+ every { settings.resumeGrowthLastSent = capture(updateSlot) } returns Unit
+
+ storage.updateSentState(Event.GrowthData.FirstAppOpenForDay)
+
+ assertTrue(updateSlot.captured > 0)
+ }
+
+ @Test
+ fun `GIVEN that it has been less than 24 hours since uri load sent WHEN checked for sending THEN will not be sent`() = runTest(dispatcher) {
+ val currentTime = System.currentTimeMillis()
+ every { settings.uriLoadGrowthLastSent } returns currentTime
+
+ val result = storage.shouldTrack(Event.GrowthData.FirstUriLoadForDay)
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `GIVEN that it has been more than 24 hours since uri load sent WHEN checked for sending THEN will be sent`() = runTest(dispatcher) {
+ val currentTime = System.currentTimeMillis()
+ installTime = currentTime - (dayMillis + 1)
+ every { settings.uriLoadGrowthLastSent } returns currentTime - 1000 * 60 * 60 * 24 * 2
+
+ val result = storage.shouldTrack(Event.GrowthData.FirstUriLoadForDay)
+
+ assertTrue(result)
+ }
+
+ @Test
+ fun `WHEN uri load updated THEN settings updated accordingly`() = runTest(dispatcher) {
+ val updateSlot = slot<Long>()
+ every { settings.uriLoadGrowthLastSent } returns 0
+ every { settings.uriLoadGrowthLastSent = capture(updateSlot) } returns Unit
+
+ storage.updateSentState(Event.GrowthData.FirstUriLoadForDay)
+
+ assertTrue(updateSlot.captured > 0)
+ }
+
+ @Test
+ fun `GIVEN first week activated days of use and search use thresholds reached THEN will be sent`() = runTest(dispatcher) {
+ val currentTime = System.currentTimeMillis()
+ installTime = currentTime - (dayMillis * 5)
+ every { settings.growthEarlyUseCount.value } returns 3
+ every { settings.growthEarlySearchUsed } returns true
+ every { settings.growthUserActivatedSent } returns false
+
+ val result = storage.shouldTrack(Event.GrowthData.UserActivated(fromSearch = false))
+
+ assertTrue(result)
+ }
+
+ @Test
+ fun `GIVEN first week activated days of use threshold not reached THEN will not be sent`() = runTest(dispatcher) {
+ val currentTime = System.currentTimeMillis()
+ installTime = currentTime - (dayMillis * 5)
+ every { settings.growthEarlyUseCount.value } returns 1
+ every { settings.growthEarlySearchUsed } returns true
+ every { settings.growthUserActivatedSent } returns false
+
+ val result = storage.shouldTrack(Event.GrowthData.UserActivated(fromSearch = false))
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `GIVEN first week activated search use threshold not reached THEN will not be sent`() = runTest(dispatcher) {
+ val currentTime = System.currentTimeMillis()
+ installTime = currentTime - (dayMillis * 5)
+ every { settings.growthEarlyUseCount.value } returns 3
+ every { settings.growthEarlySearchUsed } returns false
+ every { settings.growthUserActivatedSent } returns false
+
+ val result = storage.shouldTrack(Event.GrowthData.UserActivated(fromSearch = false))
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `GIVEN first week activated already sent WHEN first week activated signal sent THEN userActivated will not be sent`() = runTest(dispatcher) {
+ val currentTime = System.currentTimeMillis()
+ installTime = currentTime - (dayMillis * 5)
+ every { settings.growthEarlyUseCount.value } returns 3
+ every { settings.growthEarlySearchUsed } returns true
+ every { settings.growthUserActivatedSent } returns true
+
+ val result = storage.shouldTrack(Event.GrowthData.UserActivated(fromSearch = false))
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `WHEN first week usage signal is sent a full day after last sent THEN settings will be updated accordingly`() = runTest(dispatcher) {
+ val captureSent = slot<Long>()
+ val currentTime = System.currentTimeMillis()
+ installTime = currentTime - (dayMillis * 3)
+ every { settings.growthEarlyUseCount.value } returns 1
+ every { settings.growthEarlyUseCount.increment() } just Runs
+ every { settings.growthEarlyUseCountLastIncrement } returns 0L
+ every { settings.growthEarlyUseCountLastIncrement = capture(captureSent) } returns Unit
+
+ storage.updatePersistentState(Event.GrowthData.UserActivated(fromSearch = false))
+
+ assertTrue(captureSent.captured > 0L)
+ }
+
+ @Test
+ fun `WHEN first week usage signal is sent less than a full day after last sent THEN settings will not be updated`() = runTest(dispatcher) {
+ val captureSent = slot<Long>()
+ val currentTime = System.currentTimeMillis()
+ installTime = currentTime - (dayMillis * 3)
+ val lastUsageIncrementTime = currentTime - (dayMillis / 2)
+ every { settings.growthEarlyUseCount.value } returns 1
+ every { settings.growthEarlyUseCountLastIncrement } returns lastUsageIncrementTime
+ every { settings.growthEarlyUseCountLastIncrement = capture(captureSent) } returns Unit
+
+ storage.updatePersistentState(Event.GrowthData.UserActivated(fromSearch = false))
+
+ assertFalse(captureSent.isCaptured)
+ }
+
+ @Test
+ fun `WHEN first week search activity is sent in second half of first week THEN settings will be updated`() = runTest(dispatcher) {
+ val captureSent = slot<Boolean>()
+ val currentTime = System.currentTimeMillis()
+ installTime = currentTime - (dayMillis * 3) - 100
+ every { settings.growthEarlySearchUsed } returns false
+ every { settings.growthEarlySearchUsed = capture(captureSent) } returns Unit
+
+ storage.updatePersistentState(Event.GrowthData.UserActivated(fromSearch = true))
+
+ assertTrue(captureSent.captured)
+ }
+
+ @Test
+ fun `WHEN first week search activity is sent in first half of first week THEN settings will not be updated`() = runTest(dispatcher) {
+ val captureSent = slot<Boolean>()
+ val currentTime = System.currentTimeMillis()
+ installTime = currentTime - (dayMillis * 3) + 100
+ every { settings.growthEarlySearchUsed } returns false
+ every { settings.growthEarlySearchUsed = capture(captureSent) } returns Unit
+
+ storage.updatePersistentState(Event.GrowthData.UserActivated(fromSearch = true))
+
+ assertFalse(captureSent.isCaptured)
+ }
+
+ private fun Calendar.copy() = clone() as Calendar
+ private fun Calendar.createNextDay() = copy().apply {
+ add(Calendar.DAY_OF_MONTH, 1)
+ }
+ private fun Calendar.createPreviousDay() = copy().apply {
+ add(Calendar.DAY_OF_MONTH, -1)
+ }
+ private fun Set<Calendar>.toStrings() = map {
+ formatter.format(it.time)
+ }.toSet()
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/FirstSessionPingTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/FirstSessionPingTest.kt
new file mode 100644
index 0000000000..c3e920f2ca
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/FirstSessionPingTest.kt
@@ -0,0 +1,49 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.metrics
+
+import android.content.Context
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.spyk
+import io.mockk.verify
+import org.junit.Test
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.utils.Settings
+
+internal class FirstSessionPingTest {
+
+ @Test
+ fun `checkAndSend() triggers the ping if it wasn't marked as triggered`() {
+ val mockedContext: Context = mockk(relaxed = true)
+ val mockedSettings: Settings = mockk(relaxed = true)
+ mockkStatic("org.mozilla.fenix.ext.ContextKt")
+ every { mockedContext.settings() } returns mockedSettings
+ val mockAp = spyk(FirstSessionPing(mockedContext), recordPrivateCalls = true)
+ every { mockAp.checkMetricsNotEmpty() } returns true
+ every { mockAp.wasAlreadyTriggered() } returns false
+ every { mockAp.markAsTriggered() } just Runs
+
+ mockAp.checkAndSend()
+
+ verify(exactly = 1) { mockAp.triggerPing() }
+ // Marking the ping as triggered happens in a co-routine off the main thread,
+ // so wait a bit for it.
+ verify(timeout = 5000, exactly = 1) { mockAp.markAsTriggered() }
+ }
+
+ @Test
+ fun `checkAndSend() doesn't trigger the ping again if it was marked as triggered`() {
+ val mockAp = spyk(FirstSessionPing(mockk()), recordPrivateCalls = true)
+ every { mockAp.wasAlreadyTriggered() } returns true
+
+ mockAp.checkAndSend()
+
+ verify(exactly = 0) { mockAp.triggerPing() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/InstallReferrerMetricsServiceTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/InstallReferrerMetricsServiceTest.kt
new file mode 100644
index 0000000000..8171075568
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/InstallReferrerMetricsServiceTest.kt
@@ -0,0 +1,188 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.metrics
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.MetaAttribution
+import org.mozilla.fenix.GleanMetrics.PlayStoreAttribution
+import org.mozilla.fenix.utils.Settings
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+internal class InstallReferrerMetricsServiceTest {
+ val context: Context = ApplicationProvider.getApplicationContext()
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @Test
+ fun `WHEN retrieving minimum UTM params from setting THEN result should match`() {
+ val settings = Settings(context)
+ val expected = UTMParams(source = "", medium = "", campaign = "", content = "", term = "")
+ val observed = UTMParams.fromSettings(settings)
+
+ assertEquals(observed, expected)
+ assertTrue(observed.isEmpty())
+ }
+
+ @Test
+ fun `WHEN retrieving maximum UTM params from setting THEN result should match`() {
+ val expected = UTMParams(source = "source", medium = "medium", campaign = "campaign", content = "content", term = "term")
+ val settings = Settings(context)
+
+ expected.intoSettings(settings)
+ val observed = UTMParams.fromSettings(settings)
+
+ assertEquals(observed, expected)
+
+ assertFalse(observed.isEmpty())
+ }
+
+ @Test
+ fun `WHEN parsing referrer response with no UTM params from setting THEN UTM params in settings should set to empty strings`() {
+ val settings = Settings(context)
+ val params = UTMParams.parseUTMParameters("")
+ params.recordInstallReferrer(settings)
+
+ val expected = UTMParams(source = "", medium = "", campaign = "", content = "", term = "")
+ val observed = UTMParams.fromSettings(settings)
+ assertEquals(observed, expected)
+
+ assertNull(PlayStoreAttribution.source.testGetValue())
+ assertNull(PlayStoreAttribution.medium.testGetValue())
+ assertNull(PlayStoreAttribution.campaign.testGetValue())
+ assertNull(PlayStoreAttribution.content.testGetValue())
+ assertNull(PlayStoreAttribution.term.testGetValue())
+
+ assertTrue(observed.isEmpty())
+ }
+
+ @Test
+ fun `WHEN parsing referrer response with partial UTM params from setting THEN UTM params in settings should match expected`() {
+ val settings = Settings(context)
+ val params = UTMParams.parseUTMParameters("utm_campaign=CAMPAIGN")
+ params.recordInstallReferrer(settings)
+
+ val expected = UTMParams(source = "", medium = "", campaign = "CAMPAIGN", content = "", term = "")
+ val observed = UTMParams.fromSettings(settings)
+ assertEquals(observed, expected)
+
+ assertEquals("", PlayStoreAttribution.source.testGetValue())
+ assertEquals("", PlayStoreAttribution.medium.testGetValue())
+ assertEquals("CAMPAIGN", PlayStoreAttribution.campaign.testGetValue())
+ assertEquals("", PlayStoreAttribution.content.testGetValue())
+ assertEquals("", PlayStoreAttribution.term.testGetValue())
+
+ assertFalse(observed.isEmpty())
+ }
+
+ @Test
+ fun `WHEN parsing referrer response with full UTM params from setting THEN UTM params in settings should match expected`() {
+ val settings = Settings(context)
+ val params = UTMParams.parseUTMParameters("utm_source=SOURCE&utm_medium=MEDIUM&utm_campaign=CAMPAIGN&utm_content=CONTENT&utm_term=TERM")
+ params.recordInstallReferrer(settings)
+
+ val expected = UTMParams(source = "SOURCE", medium = "MEDIUM", campaign = "CAMPAIGN", content = "CONTENT", term = "TERM")
+ val observed = UTMParams.fromSettings(settings)
+ assertEquals(expected, observed)
+
+ assertEquals("SOURCE", PlayStoreAttribution.source.testGetValue())
+ assertEquals("MEDIUM", PlayStoreAttribution.medium.testGetValue())
+ assertEquals("CAMPAIGN", PlayStoreAttribution.campaign.testGetValue())
+ assertEquals("CONTENT", PlayStoreAttribution.content.testGetValue())
+ assertEquals("TERM", PlayStoreAttribution.term.testGetValue())
+
+ assertFalse(observed.isEmpty())
+ }
+
+ @Test
+ fun `WHEN Install referrer metrics service should track is called THEN it should always return false`() {
+ val service = InstallReferrerMetricsService(context)
+ assertFalse(service.shouldTrack(Event.GrowthData.FirstAppOpenForDay))
+ }
+
+ @Test
+ fun `WHEN Install referrer metrics service starts THEN then the service type should be marketing`() {
+ val service = InstallReferrerMetricsService(context)
+ assertEquals(MetricServiceType.Marketing, service.type)
+ }
+
+ @Test
+ fun `WHEN receiving a Meta encrypted attribution THEN will decrypt correctly`() {
+ val metaParams = MetaParams.extractMetaAttribution("""{"app":12345, "t":1234567890,"source":{"data":"DATA","nonce":"NONCE"}}""")
+ val expectedMetaParams = MetaParams("12345", "1234567890", "DATA", "NONCE")
+
+ assertEquals(metaParams, expectedMetaParams)
+ }
+
+ @Test
+ fun `WHEN receiving a Meta encrypted attribution in percent format THEN will decrypt correctly`() {
+ val metaParams = MetaParams.extractMetaAttribution("%7B%22app%22%3A12345%2C%22t%22%3A1234567890%2C%22source%22%3A%7B%22data%22%3A%22DATA%22%2C%22nonce%22%3A%22NONCE%22%7D%7D")
+ val expectedMetaParams = MetaParams("12345", "1234567890", "DATA", "NONCE")
+
+ assertEquals(metaParams, expectedMetaParams)
+ }
+
+ @Test
+ fun `WHEN receiving a Meta encrypted attribution in bad format THEN it should not crash`() {
+ val metaParams = MetaParams.extractMetaAttribution("%7B%22app%22%3A12345%2C%22t%22%3A1234567890%2C%22source%22%3A%7B%22data%22%3A%22DATA%22%2C%22nonce%22%3A%22NONCE%22%7B%7D")
+
+ assertNull(metaParams)
+ }
+
+ @Test
+ fun `WHEN parsing referrer response with meta attribution THEN both UTM and Meta params should match expected`() {
+ val utmParams = UTMParams.parseUTMParameters("""utm_content={"app":12345, "t":1234567890,"source":{"data":"DATA","nonce":"NONCE"}}""")
+ val expectedUtmParams = UTMParams(source = "", medium = "", campaign = "", content = """{"app":12345, "t":1234567890,"source":{"data":"DATA","nonce":"NONCE"}}""", term = "")
+
+ assertEquals(utmParams, expectedUtmParams)
+
+ val metaParams = MetaParams.extractMetaAttribution(utmParams.content)
+ val expectedMetaParams = MetaParams("12345", "1234567890", "DATA", "NONCE")
+
+ assertEquals(metaParams, expectedMetaParams)
+ }
+
+ @Test
+ fun `WHEN recording Meta attribution THEN correct values should be recorded to telemetry`() {
+ // The data and nonce are from Meta's example https://developers.facebook.com/docs/app-ads/install-referrer/
+ val metaParams = MetaParams(
+ "12345",
+ "1234567890",
+ "afe56cf6228c6ea8c79da49186e718e92a579824596ae1d0d4d20d7793dca797bd4034ccf467bfae5c79a3981e7a2968c41949237e2b2db678c1c3d39c9ae564c5cafd52f2b77a3dc77bf1bae063114d0283b97417487207735da31ddc1531d5645a9c3e602c195a0ebf69c272aa5fda3a2d781cb47e117310164715a54c7a5a032740584e2789a7b4e596034c16425139a77e507c492b629c848573c714a03a2e7d25b9459b95842332b460f3682d19c35dbc7d53e3a51e0497ff6a6cbb367e760debc4194ae097498108df7b95eac2fa9bac4320077b510be3b7b823248bfe02ae501d9fe4ba179c7de6733c92bf89d523df9e31238ef497b9db719484cbab7531dbf6c5ea5a8087f95d59f5e4f89050e0f1dc03e464168ad76a64cca64b79",
+ "b7203c6a6fb633d16e9cf5c1",
+ )
+
+ assertNull(MetaAttribution.app.testGetValue())
+ assertNull(MetaAttribution.t.testGetValue())
+ assertNull(MetaAttribution.data.testGetValue())
+ assertNull(MetaAttribution.nonce.testGetValue())
+ metaParams.recordMetaAttribution()
+
+ val expectedApp = "12345"
+ val expectedT = "1234567890"
+ val expectedData = "afe56cf6228c6ea8c79da49186e718e92a579824596ae1d0d4d20d7793dca797bd4034ccf467bfae5c79a3981e7a2968c41949237e2b2db678c1c3d39c9ae564c5cafd52f2b77a3dc77bf1bae063114d0283b97417487207735da31ddc1531d5645a9c3e602c195a0ebf69c272aa5fda3a2d781cb47e117310164715a54c7a5a032740584e2789a7b4e596034c16425139a77e507c492b629c848573c714a03a2e7d25b9459b95842332b460f3682d19c35dbc7d53e3a51e0497ff6a6cbb367e760debc4194ae097498108df7b95eac2fa9bac4320077b510be3b7b823248bfe02ae501d9fe4ba179c7de6733c92bf89d523df9e31238ef497b9db719484cbab7531dbf6c5ea5a8087f95d59f5e4f89050e0f1dc03e464168ad76a64cca64b79"
+ val expectedNonce = "b7203c6a6fb633d16e9cf5c1"
+
+ val recordedApp = MetaAttribution.app.testGetValue()
+ assertEquals(recordedApp, expectedApp)
+ val recordedT = MetaAttribution.t.testGetValue()
+ assertEquals(recordedT, expectedT)
+ val recordedData = MetaAttribution.data.testGetValue()
+ assertEquals(recordedData, expectedData)
+ val recordedNonce = MetaAttribution.nonce.testGetValue()
+ assertEquals(recordedNonce, expectedNonce)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/MetricControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/MetricControllerTest.kt
new file mode 100644
index 0000000000..5badaf10b2
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/MetricControllerTest.kt
@@ -0,0 +1,835 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.metrics
+
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.verify
+import io.mockk.verifyAll
+import mozilla.components.feature.autofill.facts.AutofillFacts
+import mozilla.components.feature.awesomebar.facts.AwesomeBarFacts
+import mozilla.components.feature.awesomebar.provider.BookmarksStorageSuggestionProvider
+import mozilla.components.feature.awesomebar.provider.ClipboardSuggestionProvider
+import mozilla.components.feature.awesomebar.provider.HistoryStorageSuggestionProvider
+import mozilla.components.feature.awesomebar.provider.SearchSuggestionProvider
+import mozilla.components.feature.awesomebar.provider.SessionSuggestionProvider
+import mozilla.components.feature.contextmenu.facts.ContextMenuFacts
+import mozilla.components.feature.media.facts.MediaFacts
+import mozilla.components.feature.prompts.dialog.LoginDialogFacts
+import mozilla.components.feature.prompts.facts.CreditCardAutofillDialogFacts
+import mozilla.components.feature.pwa.ProgressiveWebAppFacts
+import mozilla.components.feature.search.telemetry.ads.AdsTelemetry
+import mozilla.components.feature.search.telemetry.incontent.InContentTelemetry
+import mozilla.components.feature.sitepermissions.SitePermissionsFacts
+import mozilla.components.feature.syncedtabs.facts.SyncedTabsFacts
+import mozilla.components.feature.top.sites.facts.TopSitesFacts
+import mozilla.components.support.base.Component
+import mozilla.components.support.base.facts.Action
+import mozilla.components.support.base.facts.Fact
+import mozilla.components.support.base.log.logger.Logger
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.webextensions.facts.WebExtensionFacts
+import mozilla.telemetry.glean.testing.GleanTestRule
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.AndroidAutofill
+import org.mozilla.fenix.GleanMetrics.Awesomebar
+import org.mozilla.fenix.GleanMetrics.BrowserSearch
+import org.mozilla.fenix.GleanMetrics.ContextualMenu
+import org.mozilla.fenix.GleanMetrics.CreditCards
+import org.mozilla.fenix.GleanMetrics.LoginDialog
+import org.mozilla.fenix.GleanMetrics.MediaNotification
+import org.mozilla.fenix.GleanMetrics.PerfAwesomebar
+import org.mozilla.fenix.GleanMetrics.ProgressiveWebApp
+import org.mozilla.fenix.GleanMetrics.SitePermissions
+import org.mozilla.fenix.GleanMetrics.SyncedTabs
+import org.mozilla.fenix.components.metrics.ReleaseMetricController.Companion
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.search.awesomebar.ShortcutsSuggestionProvider
+import org.mozilla.fenix.utils.Settings
+import mozilla.components.compose.browser.awesomebar.AwesomeBarFacts as ComposeAwesomeBarFacts
+
+@RunWith(FenixRobolectricTestRunner::class)
+class MetricControllerTest {
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @MockK(relaxUnitFun = true)
+ private lateinit var dataService1: MetricsService
+
+ @MockK(relaxUnitFun = true)
+ private lateinit var dataService2: MetricsService
+
+ @MockK(relaxUnitFun = true)
+ private lateinit var marketingService1: MetricsService
+
+ @MockK(relaxUnitFun = true)
+ private lateinit var marketingService2: MetricsService
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+
+ every { dataService1.type } returns MetricServiceType.Data
+ every { dataService2.type } returns MetricServiceType.Data
+ every { marketingService1.type } returns MetricServiceType.Marketing
+ every { marketingService2.type } returns MetricServiceType.Marketing
+ }
+
+ @Test
+ fun `debug metric controller emits logs`() {
+ val logger = mockk<Logger>(relaxed = true)
+ val controller = DebugMetricController(logger)
+
+ controller.start(MetricServiceType.Data)
+ verify { logger.debug("DebugMetricController: start") }
+
+ controller.stop(MetricServiceType.Data)
+ verify { logger.debug("DebugMetricController: stop") }
+ }
+
+ @Test
+ fun `release metric controller starts and stops all data services`() {
+ var enabled = true
+ val controller = ReleaseMetricController(
+ services = listOf(dataService1, marketingService1, dataService2, marketingService2),
+ isDataTelemetryEnabled = { enabled },
+ isMarketingDataTelemetryEnabled = { enabled },
+ mockk(),
+ )
+
+ controller.start(MetricServiceType.Data)
+ verify { dataService1.start() }
+ verify { dataService2.start() }
+
+ enabled = false
+
+ controller.stop(MetricServiceType.Data)
+ verify { dataService1.stop() }
+ verify { dataService2.stop() }
+
+ verifyAll(inverse = true) {
+ marketingService1.start()
+ marketingService1.stop()
+ marketingService2.start()
+ marketingService2.stop()
+ }
+ }
+
+ @Test
+ fun `release metric controller starts data service only if enabled`() {
+ val controller = ReleaseMetricController(
+ services = listOf(dataService1),
+ isDataTelemetryEnabled = { false },
+ isMarketingDataTelemetryEnabled = { true },
+ mockk(),
+ )
+
+ controller.start(MetricServiceType.Data)
+ verify(inverse = true) { dataService1.start() }
+
+ controller.stop(MetricServiceType.Data)
+ verify(inverse = true) { dataService1.stop() }
+ }
+
+ @Test
+ fun `release metric controller starts service only once`() {
+ var enabled = true
+ val controller = ReleaseMetricController(
+ services = listOf(dataService1),
+ isDataTelemetryEnabled = { enabled },
+ isMarketingDataTelemetryEnabled = { true },
+ mockk(),
+ )
+
+ controller.start(MetricServiceType.Data)
+ controller.start(MetricServiceType.Data)
+ verify(exactly = 1) { dataService1.start() }
+
+ enabled = false
+
+ controller.stop(MetricServiceType.Data)
+ controller.stop(MetricServiceType.Data)
+ verify(exactly = 1) { dataService1.stop() }
+ }
+
+ @Test
+ fun `WHEN AwesomeBar duration fact is processed THEN the correct metric is recorded`() {
+ val controller = ReleaseMetricController(emptyList(), { true }, { true }, mockk())
+ val action = mockk<Action>()
+ val duration = 1000L
+ var metadata = mapOf<String, Pair<*, Long>>(
+ ComposeAwesomeBarFacts.MetadataKeys.DURATION_PAIR to Pair(
+ mockk<HistoryStorageSuggestionProvider>(),
+ duration,
+ ),
+ )
+ var fact = Fact(
+ Component.COMPOSE_AWESOMEBAR,
+ action,
+ ComposeAwesomeBarFacts.Items.PROVIDER_DURATION,
+ metadata = metadata,
+ )
+ // Verify history based suggestions
+ assertNull(PerfAwesomebar.historySuggestions.testGetValue())
+
+ with(controller) {
+ fact.process()
+ }
+
+ assertNotNull(PerfAwesomebar.historySuggestions.testGetValue())
+
+ // Verify bookmark based suggestions
+ metadata = mapOf(
+ ComposeAwesomeBarFacts.MetadataKeys.DURATION_PAIR to Pair(
+ mockk<BookmarksStorageSuggestionProvider>(),
+ duration,
+ ),
+ )
+ fact = fact.copy(metadata = metadata)
+ assertNull(PerfAwesomebar.bookmarkSuggestions.testGetValue())
+
+ with(controller) {
+ fact.process()
+ }
+
+ assertNotNull(PerfAwesomebar.bookmarkSuggestions.testGetValue())
+
+ // Verify session based suggestions
+ metadata = mapOf(
+ ComposeAwesomeBarFacts.MetadataKeys.DURATION_PAIR to Pair(
+ mockk<SessionSuggestionProvider>(),
+ duration,
+ ),
+ )
+ fact = fact.copy(metadata = metadata)
+ assertNull(PerfAwesomebar.sessionSuggestions.testGetValue())
+
+ with(controller) {
+ fact.process()
+ }
+
+ assertNotNull(PerfAwesomebar.sessionSuggestions.testGetValue())
+
+ // Verify search engine suggestions
+ metadata = mapOf(
+ ComposeAwesomeBarFacts.MetadataKeys.DURATION_PAIR to Pair(
+ mockk<SearchSuggestionProvider>(),
+ duration,
+ ),
+ )
+ fact = fact.copy(metadata = metadata)
+ assertNull(PerfAwesomebar.searchEngineSuggestions.testGetValue())
+
+ with(controller) {
+ fact.process()
+ }
+
+ assertNotNull(PerfAwesomebar.searchEngineSuggestions.testGetValue())
+
+ // Verify clipboard based suggestions
+ metadata = mapOf(
+ ComposeAwesomeBarFacts.MetadataKeys.DURATION_PAIR to Pair(
+ mockk<ClipboardSuggestionProvider>(),
+ duration,
+ ),
+ )
+ fact = fact.copy(metadata = metadata)
+ assertNull(PerfAwesomebar.clipboardSuggestions.testGetValue())
+
+ with(controller) {
+ fact.process()
+ }
+
+ assertNotNull(PerfAwesomebar.clipboardSuggestions.testGetValue())
+
+ // Verify shortcut based suggestions
+ metadata = mapOf(
+ ComposeAwesomeBarFacts.MetadataKeys.DURATION_PAIR to Pair(
+ mockk<ShortcutsSuggestionProvider>(),
+ duration,
+ ),
+ )
+ fact = fact.copy(metadata = metadata)
+ assertNull(PerfAwesomebar.shortcutsSuggestions.testGetValue())
+
+ with(controller) {
+ fact.process()
+ }
+
+ assertNotNull(PerfAwesomebar.shortcutsSuggestions.testGetValue())
+ }
+
+ @Test
+ fun `release metric controller starts and stops all marketing services`() {
+ var enabled = true
+ val controller = ReleaseMetricController(
+ services = listOf(dataService1, marketingService1, dataService2, marketingService2),
+ isDataTelemetryEnabled = { enabled },
+ isMarketingDataTelemetryEnabled = { enabled },
+ mockk(),
+ )
+
+ controller.start(MetricServiceType.Marketing)
+ verify { marketingService1.start() }
+ verify { marketingService2.start() }
+
+ enabled = false
+
+ controller.stop(MetricServiceType.Marketing)
+ verify { marketingService1.stop() }
+ verify { marketingService2.stop() }
+
+ verifyAll(inverse = true) {
+ dataService1.start()
+ dataService1.stop()
+ dataService2.start()
+ dataService2.stop()
+ }
+ }
+
+ @Test
+ fun `topsites fact should set value in SharedPreference`() {
+ val enabled = true
+ val settings: Settings = mockk(relaxed = true)
+ val controller = ReleaseMetricController(
+ services = listOf(dataService1),
+ isDataTelemetryEnabled = { enabled },
+ isMarketingDataTelemetryEnabled = { enabled },
+ settings,
+ )
+
+ val fact = Fact(
+ Component.FEATURE_TOP_SITES,
+ Action.INTERACTION,
+ TopSitesFacts.Items.COUNT,
+ "1",
+ )
+
+ verify(exactly = 0) { settings.topSitesSize = any() }
+ with(controller) {
+ fact.process()
+ }
+ verify(exactly = 1) { settings.topSitesSize = any() }
+ }
+
+ @Test
+ fun `web extension fact should set value in SharedPreference`() {
+ val enabled = true
+ val settings = Settings(testContext)
+ val controller = ReleaseMetricController(
+ services = listOf(dataService1),
+ isDataTelemetryEnabled = { enabled },
+ isMarketingDataTelemetryEnabled = { enabled },
+ settings,
+ )
+ val fact = Fact(
+ Component.SUPPORT_WEBEXTENSIONS,
+ Action.INTERACTION,
+ WebExtensionFacts.Items.WEB_EXTENSIONS_INITIALIZED,
+ metadata = mapOf(
+ "installed" to listOf("test1", "test2", "test3", "test4"),
+ "enabled" to listOf("test2", "test4"),
+ ),
+ )
+
+ assertEquals(settings.installedAddonsCount, 0)
+ assertEquals(settings.installedAddonsList, "")
+ assertEquals(settings.enabledAddonsCount, 0)
+ assertEquals(settings.enabledAddonsList, "")
+ with(controller) {
+ fact.process()
+ }
+ assertEquals(settings.installedAddonsCount, 4)
+ assertEquals(settings.installedAddonsList, "test1,test2,test3,test4")
+ assertEquals(settings.enabledAddonsCount, 2)
+ assertEquals(settings.enabledAddonsList, "test2,test4")
+ }
+
+ @Test
+ fun `WHEN processing a fact with FEATURE_PROMPTS component THEN the right metric is recorded with no extras`() {
+ val controller = ReleaseMetricController(emptyList(), { true }, { true }, mockk())
+ val action = mockk<Action>()
+
+ // Verify display interaction
+ assertNull(LoginDialog.displayed.testGetValue())
+ var fact = Fact(Component.FEATURE_PROMPTS, action, LoginDialogFacts.Items.DISPLAY)
+
+ controller.run {
+ fact.process()
+ }
+
+ assertNotNull(LoginDialog.displayed.testGetValue())
+ assertEquals(1, LoginDialog.displayed.testGetValue()!!.size)
+ assertNull(LoginDialog.displayed.testGetValue()!!.single().extra)
+
+ // Verify cancel interaction
+ assertNull(LoginDialog.cancelled.testGetValue())
+ fact = Fact(Component.FEATURE_PROMPTS, action, LoginDialogFacts.Items.CANCEL)
+
+ controller.run {
+ fact.process()
+ }
+
+ assertNotNull(LoginDialog.cancelled.testGetValue())
+ assertEquals(1, LoginDialog.cancelled.testGetValue()!!.size)
+ assertNull(LoginDialog.cancelled.testGetValue()!!.single().extra)
+
+ // Verify never save interaction
+ assertNull(LoginDialog.neverSave.testGetValue())
+ fact = Fact(Component.FEATURE_PROMPTS, action, LoginDialogFacts.Items.NEVER_SAVE)
+
+ controller.run {
+ fact.process()
+ }
+
+ assertNotNull(LoginDialog.neverSave.testGetValue())
+ assertEquals(1, LoginDialog.neverSave.testGetValue()!!.size)
+ assertNull(LoginDialog.neverSave.testGetValue()!!.single().extra)
+
+ // Verify save interaction
+ assertNull(LoginDialog.saved.testGetValue())
+ fact = Fact(Component.FEATURE_PROMPTS, action, LoginDialogFacts.Items.SAVE)
+
+ controller.run {
+ fact.process()
+ }
+
+ assertNotNull(LoginDialog.saved.testGetValue())
+ assertEquals(1, LoginDialog.saved.testGetValue()!!.size)
+ assertNull(LoginDialog.saved.testGetValue()!!.single().extra)
+ }
+
+ @Test
+ fun `WHEN processing a FEATURE_MEDIA NOTIFICATION fact THEN the right metric is recorded`() {
+ val controller = ReleaseMetricController(emptyList(), { true }, { true }, mockk())
+ // Verify the play action
+ var fact = Fact(Component.FEATURE_MEDIA, Action.PLAY, MediaFacts.Items.NOTIFICATION)
+ assertNull(MediaNotification.play.testGetValue())
+
+ controller.run {
+ fact.process()
+ }
+
+ assertNotNull(MediaNotification.play.testGetValue())
+ assertEquals(1, MediaNotification.play.testGetValue()!!.size)
+ assertNull(MediaNotification.play.testGetValue()!!.single().extra)
+
+ // Verify the pause action
+ fact = Fact(Component.FEATURE_MEDIA, Action.PAUSE, MediaFacts.Items.NOTIFICATION)
+ assertNull(MediaNotification.pause.testGetValue())
+
+ controller.run {
+ fact.process()
+ }
+
+ assertNotNull(MediaNotification.pause.testGetValue())
+ assertEquals(1, MediaNotification.pause.testGetValue()!!.size)
+ assertNull(MediaNotification.pause.testGetValue()!!.single().extra)
+ }
+
+ @Test
+ fun `WHEN processing a FEATURE_AUTOFILL fact THEN the right metric is recorded`() {
+ val controller = ReleaseMetricController(emptyList(), { true }, { true }, mockk())
+ var fact = Fact(
+ Component.FEATURE_AUTOFILL,
+ mockk(relaxed = true),
+ AutofillFacts.Items.AUTOFILL_REQUEST,
+ metadata = mapOf(AutofillFacts.Metadata.HAS_MATCHING_LOGINS to true),
+ )
+
+ with(controller) {
+ assertNull(AndroidAutofill.requestMatchingLogins.testGetValue())
+
+ fact.process()
+
+ assertNotNull(AndroidAutofill.requestMatchingLogins.testGetValue())
+
+ fact = fact.copy(metadata = mapOf(AutofillFacts.Metadata.HAS_MATCHING_LOGINS to false))
+ assertNull(AndroidAutofill.requestNoMatchingLogins.testGetValue())
+
+ fact.process()
+
+ assertNotNull(AndroidAutofill.requestNoMatchingLogins.testGetValue())
+
+ fact = fact.copy(item = AutofillFacts.Items.AUTOFILL_SEARCH, action = Action.DISPLAY, metadata = null)
+ assertNull(AndroidAutofill.searchDisplayed.testGetValue())
+
+ fact.process()
+
+ assertNotNull(AndroidAutofill.searchDisplayed.testGetValue())
+
+ fact = fact.copy(action = Action.SELECT)
+ assertNull(AndroidAutofill.searchItemSelected.testGetValue())
+
+ fact.process()
+
+ assertNotNull(AndroidAutofill.searchItemSelected.testGetValue())
+
+ fact = fact.copy(item = AutofillFacts.Items.AUTOFILL_CONFIRMATION, action = Action.CONFIRM)
+ assertNull(AndroidAutofill.confirmSuccessful.testGetValue())
+
+ fact.process()
+
+ assertNotNull(AndroidAutofill.confirmSuccessful.testGetValue())
+
+ fact = fact.copy(action = Action.DISPLAY)
+ assertNull(AndroidAutofill.confirmCancelled.testGetValue())
+
+ fact.process()
+
+ assertNotNull(AndroidAutofill.confirmCancelled.testGetValue())
+
+ fact = fact.copy(item = AutofillFacts.Items.AUTOFILL_LOCK, action = Action.CONFIRM)
+ assertNull(AndroidAutofill.unlockSuccessful.testGetValue())
+
+ fact.process()
+
+ assertNotNull(AndroidAutofill.unlockSuccessful.testGetValue())
+
+ fact = fact.copy(action = Action.DISPLAY)
+ assertNull(AndroidAutofill.unlockCancelled.testGetValue())
+
+ fact.process()
+
+ assertNotNull(AndroidAutofill.unlockCancelled.testGetValue())
+ }
+ }
+
+ @Test
+ fun `WHEN processing a ContextualMenu fact THEN the right metric is recorded`() {
+ val controller = ReleaseMetricController(emptyList(), { true }, { true }, mockk())
+ val action = mockk<Action>()
+ // Verify copy button interaction
+ var fact = Fact(
+ Component.FEATURE_CONTEXTMENU,
+ action,
+ ContextMenuFacts.Items.TEXT_SELECTION_OPTION,
+ metadata = mapOf("textSelectionOption" to Companion.CONTEXT_MENU_COPY),
+ )
+ assertNull(ContextualMenu.copyTapped.testGetValue())
+
+ with(controller) {
+ fact.process()
+ }
+
+ assertNotNull(ContextualMenu.copyTapped.testGetValue())
+ assertEquals(1, ContextualMenu.copyTapped.testGetValue()!!.size)
+ assertNull(ContextualMenu.copyTapped.testGetValue()!!.single().extra)
+
+ // Verify search button interaction
+ fact = Fact(
+ Component.FEATURE_CONTEXTMENU,
+ action,
+ ContextMenuFacts.Items.TEXT_SELECTION_OPTION,
+ metadata = mapOf("textSelectionOption" to Companion.CONTEXT_MENU_SEARCH),
+ )
+ assertNull(ContextualMenu.searchTapped.testGetValue())
+
+ with(controller) {
+ fact.process()
+ }
+
+ assertNotNull(ContextualMenu.searchTapped.testGetValue())
+ assertEquals(1, ContextualMenu.searchTapped.testGetValue()!!.size)
+ assertNull(ContextualMenu.searchTapped.testGetValue()!!.single().extra)
+
+ // Verify select all button interaction
+ fact = Fact(
+ Component.FEATURE_CONTEXTMENU,
+ action,
+ ContextMenuFacts.Items.TEXT_SELECTION_OPTION,
+ metadata = mapOf("textSelectionOption" to Companion.CONTEXT_MENU_SELECT_ALL),
+ )
+ assertNull(ContextualMenu.selectAllTapped.testGetValue())
+
+ with(controller) {
+ fact.process()
+ }
+
+ assertNotNull(ContextualMenu.selectAllTapped.testGetValue())
+ assertEquals(1, ContextualMenu.selectAllTapped.testGetValue()!!.size)
+ assertNull(ContextualMenu.selectAllTapped.testGetValue()!!.single().extra)
+
+ // Verify share button interaction
+ fact = Fact(
+ Component.FEATURE_CONTEXTMENU,
+ action,
+ ContextMenuFacts.Items.TEXT_SELECTION_OPTION,
+ metadata = mapOf("textSelectionOption" to Companion.CONTEXT_MENU_SHARE),
+ )
+ assertNull(ContextualMenu.shareTapped.testGetValue())
+
+ with(controller) {
+ fact.process()
+ }
+
+ assertNotNull(ContextualMenu.shareTapped.testGetValue())
+ assertEquals(1, ContextualMenu.shareTapped.testGetValue()!!.size)
+ assertNull(ContextualMenu.shareTapped.testGetValue()!!.single().extra)
+ }
+
+ @Test
+ fun `WHEN processing a CreditCardAutofillDialog fact THEN the right metric is recorded`() {
+ val controller = ReleaseMetricController(emptyList(), { true }, { true }, mockk())
+ val action = mockk<Action>(relaxed = true)
+ val itemsToEvents = listOf(
+ CreditCardAutofillDialogFacts.Items.AUTOFILL_CREDIT_CARD_FORM_DETECTED to CreditCards.formDetected,
+ CreditCardAutofillDialogFacts.Items.AUTOFILL_CREDIT_CARD_SUCCESS to CreditCards.autofilled,
+ CreditCardAutofillDialogFacts.Items.AUTOFILL_CREDIT_CARD_PROMPT_SHOWN to CreditCards.autofillPromptShown,
+ CreditCardAutofillDialogFacts.Items.AUTOFILL_CREDIT_CARD_PROMPT_EXPANDED to CreditCards.autofillPromptExpanded,
+ CreditCardAutofillDialogFacts.Items.AUTOFILL_CREDIT_CARD_PROMPT_DISMISSED to CreditCards.autofillPromptDismissed,
+ CreditCardAutofillDialogFacts.Items.AUTOFILL_CREDIT_CARD_CREATED to CreditCards.savePromptCreate,
+ CreditCardAutofillDialogFacts.Items.AUTOFILL_CREDIT_CARD_UPDATED to CreditCards.savePromptUpdate,
+ CreditCardAutofillDialogFacts.Items.AUTOFILL_CREDIT_CARD_SAVE_PROMPT_SHOWN to CreditCards.savePromptShown,
+ )
+
+ itemsToEvents.forEach { (item, event) ->
+ val fact = Fact(Component.FEATURE_PROMPTS, action, item)
+ controller.run {
+ fact.process()
+ }
+
+ assertEquals(1, event.testGetValue()!!.size)
+ assertEquals(null, event.testGetValue()!!.single().extra)
+ }
+ }
+
+ @Test
+ fun `GIVEN pwa facts WHEN they are processed THEN the right metric is recorded`() {
+ val controller = ReleaseMetricController(emptyList(), { true }, { true }, mockk())
+ val action = mockk<Action>(relaxed = true)
+
+ // a PWA shortcut from homescreen was opened
+ val openPWA = Fact(
+ Component.FEATURE_PWA,
+ action,
+ ProgressiveWebAppFacts.Items.HOMESCREEN_ICON_TAP,
+ )
+
+ assertNull(ProgressiveWebApp.homescreenTap.testGetValue())
+ controller.run {
+ openPWA.process()
+ }
+ assertNotNull(ProgressiveWebApp.homescreenTap.testGetValue())
+
+ // a PWA shortcut was installed
+ val installPWA = Fact(
+ Component.FEATURE_PWA,
+ action,
+ ProgressiveWebAppFacts.Items.INSTALL_SHORTCUT,
+ )
+
+ assertNull(ProgressiveWebApp.installTap.testGetValue())
+
+ controller.run {
+ installPWA.process()
+ }
+
+ assertNotNull(ProgressiveWebApp.installTap.testGetValue())
+ }
+
+ @Test
+ fun `WHEN processing a suggestion fact THEN the right metric is recorded`() {
+ val controller = ReleaseMetricController(emptyList(), { true }, { true }, mockk())
+
+ // Verify synced tabs suggestion clicked
+ assertNull(SyncedTabs.syncedTabsSuggestionClicked.testGetValue())
+ var fact = Fact(Component.FEATURE_SYNCEDTABS, Action.CANCEL, SyncedTabsFacts.Items.SYNCED_TABS_SUGGESTION_CLICKED)
+
+ with(controller) {
+ fact.process()
+ }
+
+ assertNotNull(SyncedTabs.syncedTabsSuggestionClicked.testGetValue())
+
+ // Verify bookmark suggestion clicked
+ assertNull(Awesomebar.bookmarkSuggestionClicked.testGetValue())
+ fact = Fact(Component.FEATURE_AWESOMEBAR, Action.CANCEL, AwesomeBarFacts.Items.BOOKMARK_SUGGESTION_CLICKED)
+
+ with(controller) {
+ fact.process()
+ }
+
+ assertNotNull(Awesomebar.bookmarkSuggestionClicked.testGetValue())
+
+ // Verify clipboard suggestion clicked
+ assertNull(Awesomebar.clipboardSuggestionClicked.testGetValue())
+ fact = Fact(Component.FEATURE_AWESOMEBAR, Action.CANCEL, AwesomeBarFacts.Items.CLIPBOARD_SUGGESTION_CLICKED)
+
+ with(controller) {
+ fact.process()
+ }
+
+ assertNotNull(Awesomebar.clipboardSuggestionClicked.testGetValue())
+
+ // Verify history suggestion clicked
+ assertNull(Awesomebar.historySuggestionClicked.testGetValue())
+ fact = Fact(Component.FEATURE_AWESOMEBAR, Action.CANCEL, AwesomeBarFacts.Items.HISTORY_SUGGESTION_CLICKED)
+
+ with(controller) {
+ fact.process()
+ }
+
+ assertNotNull(Awesomebar.historySuggestionClicked.testGetValue())
+
+ // Verify search action clicked
+ assertNull(Awesomebar.searchActionClicked.testGetValue())
+ fact = Fact(Component.FEATURE_AWESOMEBAR, Action.CANCEL, AwesomeBarFacts.Items.SEARCH_ACTION_CLICKED)
+
+ with(controller) {
+ fact.process()
+ }
+
+ assertNotNull(Awesomebar.searchActionClicked.testGetValue())
+
+ // Verify bookmark opened tab suggestion clicked
+ assertNull(Awesomebar.openedTabSuggestionClicked.testGetValue())
+ fact = Fact(Component.FEATURE_AWESOMEBAR, Action.CANCEL, AwesomeBarFacts.Items.OPENED_TAB_SUGGESTION_CLICKED)
+
+ with(controller) {
+ fact.process()
+ }
+
+ assertNotNull(Awesomebar.openedTabSuggestionClicked.testGetValue())
+
+ // Verify a search term suggestion clicked
+ assertNull(Awesomebar.searchTermSuggestionClicked.testGetValue())
+ fact = Fact(Component.FEATURE_AWESOMEBAR, Action.CANCEL, AwesomeBarFacts.Items.SEARCH_TERM_SUGGESTION_CLICKED)
+
+ with(controller) {
+ fact.process()
+ }
+
+ assertNotNull(Awesomebar.searchTermSuggestionClicked.testGetValue())
+ }
+
+ @Test
+ fun `GIVEN advertising search facts WHEN the list is processed THEN the right metric is recorded`() {
+ val controller = ReleaseMetricController(emptyList(), { true }, { false }, mockk())
+ val action = mockk<Action>()
+
+ // an ad was clicked in a Search Engine Result Page
+ val addClickedInSearchFact = Fact(
+ Component.FEATURE_SEARCH,
+ action,
+ AdsTelemetry.SERP_ADD_CLICKED,
+ "provider",
+ )
+
+ assertNull(BrowserSearch.adClicks["provider"].testGetValue())
+ controller.run {
+ addClickedInSearchFact.process()
+ }
+ assertNotNull(BrowserSearch.adClicks["provider"].testGetValue())
+ assertEquals(1, BrowserSearch.adClicks["provider"].testGetValue())
+
+ // the user opened a Search Engine Result Page of one of our search providers which contains ads
+ val searchWithAdsOpenedFact = Fact(
+ Component.FEATURE_SEARCH,
+ action,
+ AdsTelemetry.SERP_SHOWN_WITH_ADDS,
+ "provider",
+ )
+
+ assertNull(BrowserSearch.withAds["provider"].testGetValue())
+
+ controller.run {
+ searchWithAdsOpenedFact.process()
+ }
+
+ assertNotNull(BrowserSearch.withAds["provider"].testGetValue())
+ assertEquals(1, BrowserSearch.withAds["provider"].testGetValue())
+
+ // the user performed a search
+ val inContentSearchFact = Fact(
+ Component.FEATURE_SEARCH,
+ action,
+ InContentTelemetry.IN_CONTENT_SEARCH,
+ "provider",
+ )
+
+ assertNull(BrowserSearch.inContent["provider"].testGetValue())
+
+ controller.run {
+ inContentSearchFact.process()
+ }
+
+ assertNotNull(BrowserSearch.inContent["provider"].testGetValue())
+ assertEquals(1, BrowserSearch.inContent["provider"].testGetValue())
+
+ // the user performed another search
+ controller.run {
+ inContentSearchFact.process()
+ }
+
+ assertEquals(2, BrowserSearch.inContent["provider"].testGetValue())
+ }
+
+ @Test
+ fun `GIVEN a site permissions prompt is shown WHEN processing the fact THEN the right metric is recorded`() {
+ val controller = ReleaseMetricController(emptyList(), { true }, { true }, mockk())
+ val fact = Fact(
+ component = Component.FEATURE_SITEPERMISSIONS,
+ action = Action.DISPLAY,
+ item = SitePermissionsFacts.Items.PERMISSIONS,
+ value = "test",
+ )
+ assertNull(SitePermissions.promptShown.testGetValue())
+
+ controller.run {
+ fact.process()
+ }
+
+ assertEquals(1, SitePermissions.promptShown.testGetValue()!!.size)
+ assertEquals("test", SitePermissions.promptShown.testGetValue()!!.single().extra!!["permissions"])
+ }
+
+ @Test
+ fun `GIVEN site permissions are allowed WHEN processing the fact THEN the right metric is recorded`() {
+ val controller = ReleaseMetricController(emptyList(), { true }, { true }, mockk())
+ val fact = Fact(
+ component = Component.FEATURE_SITEPERMISSIONS,
+ action = Action.CONFIRM,
+ item = SitePermissionsFacts.Items.PERMISSIONS,
+ value = "allow",
+ )
+ assertNull(SitePermissions.promptShown.testGetValue())
+
+ controller.run {
+ fact.process()
+ }
+
+ assertEquals(1, SitePermissions.permissionsAllowed.testGetValue()!!.size)
+ assertEquals("allow", SitePermissions.permissionsAllowed.testGetValue()!!.single().extra!!["permissions"])
+ }
+
+ @Test
+ fun `GIVEN site permissions are denied WHEN processing the fact THEN the right metric is recorded`() {
+ val controller = ReleaseMetricController(emptyList(), { true }, { true }, mockk())
+ val fact = Fact(
+ component = Component.FEATURE_SITEPERMISSIONS,
+ action = Action.CANCEL,
+ item = SitePermissionsFacts.Items.PERMISSIONS,
+ value = "deny",
+ )
+ assertNull(SitePermissions.promptShown.testGetValue())
+
+ controller.run {
+ fact.process()
+ }
+
+ assertEquals(1, SitePermissions.permissionsDenied.testGetValue()!!.size)
+ assertEquals("deny", SitePermissions.permissionsDenied.testGetValue()!!.single().extra!!["permissions"])
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/MetricsUtilsTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/MetricsUtilsTest.kt
new file mode 100644
index 0000000000..800c80a245
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/MetricsUtilsTest.kt
@@ -0,0 +1,103 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.metrics
+
+import android.content.Context
+import android.util.Base64
+import com.google.android.gms.ads.identifier.AdvertisingIdClient
+import com.google.android.gms.common.GooglePlayServicesNotAvailableException
+import com.google.android.gms.common.GooglePlayServicesRepairableException
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.mockkStatic
+import io.mockk.slot
+import io.mockk.unmockkStatic
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import java.io.IOException
+
+class MetricsUtilsTest {
+
+ private val context: Context = mockk(relaxed = true)
+
+ @Test
+ fun `getAdvertisingID() returns null if the API throws`() {
+ mockkStatic("com.google.android.gms.ads.identifier.AdvertisingIdClient")
+
+ val exceptions = listOf(
+ GooglePlayServicesNotAvailableException(1),
+ GooglePlayServicesRepairableException(0, "", mockk()),
+ IllegalStateException(),
+ IOException(),
+ )
+
+ exceptions.forEach {
+ every {
+ AdvertisingIdClient.getAdvertisingIdInfo(any())
+ } throws it
+
+ assertNull(MetricsUtils.getAdvertisingID(context))
+ }
+
+ unmockkStatic("com.google.android.gms.ads.identifier.AdvertisingIdClient")
+ }
+
+ @Test
+ fun `getAdvertisingID() returns null if the API returns null info`() {
+ mockkStatic(AdvertisingIdClient::class)
+ every { AdvertisingIdClient.getAdvertisingIdInfo(any()) } returns null
+
+ assertNull(MetricsUtils.getAdvertisingID(context))
+ }
+
+ @Test
+ fun `getAdvertisingID() returns a valid string if the API returns a valid ID`() {
+ val testId = "test-value-id"
+
+ mockkStatic(AdvertisingIdClient::class)
+ every {
+ AdvertisingIdClient.getAdvertisingIdInfo(any())
+ } returns AdvertisingIdClient.Info(testId, false)
+
+ assertEquals(testId, MetricsUtils.getAdvertisingID(context))
+ }
+
+ @Test
+ fun `getHashedIdentifier() returns a hashed identifier`() = runTest {
+ val testId = "test-value-id"
+ val testPackageName = "org.mozilla-test.fenix"
+ val mockedHexReturn = "mocked-HEX"
+
+ // Mock the Base64 to record the byte array that is passed in,
+ // which is the actual digest. We can't simply test the return value
+ // of |getHashedIdentifier| as these Android tests require us to mock
+ // Android-specific APIs.
+ mockkStatic(Base64::class)
+ val shaDigest = slot<ByteArray>()
+ every {
+ Base64.encodeToString(capture(shaDigest), any())
+ } returns mockedHexReturn
+
+ // Get the hash identifier.
+ mockkObject(MetricsUtils)
+ every { MetricsUtils.getAdvertisingID(context) } returns testId
+ every { MetricsUtils.getHashingSalt() } returns testPackageName
+ assertEquals(mockedHexReturn, MetricsUtils.getHashedIdentifier(context))
+
+ // Check that the digest of the identifier matches with what we expect.
+ // Please note that in the real world, Base64.encodeToString would encode
+ // this to something much shorter, which we'd send with the ping.
+ val expectedDigestBytes =
+ "[52, -79, -84, 79, 101, 22, -82, -44, -44, -14, 21, 15, 48, 88, -94, -74, -8, 25, -72, -120, -37, 108, 47, 16, 2, -37, 126, 41, 102, -92, 103, 24]"
+ assertEquals(expectedDigestBytes, shaDigest.captured.contentToString())
+ }
+
+ companion object {
+ const val ENGINE_SOURCE_IDENTIFIER = "google-2018"
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/MetricsUtilsTestRobolectric.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/MetricsUtilsTestRobolectric.kt
new file mode 100644
index 0000000000..1ff6fb3b0c
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/metrics/MetricsUtilsTestRobolectric.kt
@@ -0,0 +1,273 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.metrics
+
+import io.mockk.every
+import io.mockk.mockk
+import mozilla.components.browser.state.search.SearchEngine
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.Events
+import org.mozilla.fenix.GleanMetrics.Metrics
+import org.mozilla.fenix.components.metrics.MetricsUtilsTest.Companion.ENGINE_SOURCE_IDENTIFIER
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+/**
+ * Just the Robolectric tests for MetricsUtil. Splitting these files out means our other tests will run more quickly.
+ * FenixRobolectricTestRunner also breaks our ability to use mockkStatic on Base64.
+ */
+@RunWith(FenixRobolectricTestRunner::class) // for gleanTestRule
+class MetricsUtilsTestRobolectric {
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @Test
+ fun `given a CUSTOM engine, when the search source is a ACTION the proper labeled metric is recorded`() {
+ assertNull(Metrics.searchCount["custom.action"].testGetValue())
+
+ val engine: SearchEngine = mockk(relaxed = true)
+
+ every { engine.id } returns ENGINE_SOURCE_IDENTIFIER
+ every { engine.type } returns SearchEngine.Type.CUSTOM
+
+ MetricsUtils.recordSearchMetrics(
+ engine,
+ false,
+ MetricsUtils.Source.ACTION,
+ )
+
+ assertNotNull(Metrics.searchCount["custom.action"].testGetValue())
+ }
+
+ @Test
+ fun `given a CUSTOM engine, when the search source is a SHORTCUT the proper labeled metric is recorded`() {
+ assertNull(Metrics.searchCount["custom.shortcut"].testGetValue())
+
+ val engine: SearchEngine = mockk(relaxed = true)
+
+ every { engine.id } returns ENGINE_SOURCE_IDENTIFIER
+ every { engine.type } returns SearchEngine.Type.CUSTOM
+
+ MetricsUtils.recordSearchMetrics(
+ engine,
+ false,
+ MetricsUtils.Source.SHORTCUT,
+ )
+
+ assertNotNull(Metrics.searchCount["custom.shortcut"].testGetValue())
+ }
+
+ @Test
+ fun `given a CUSTOM engine, when the search source is a SUGGESTION the proper labeled metric is recorded`() {
+ assertNull(Metrics.searchCount["custom.suggestion"].testGetValue())
+
+ val engine: SearchEngine = mockk(relaxed = true)
+
+ every { engine.id } returns ENGINE_SOURCE_IDENTIFIER
+ every { engine.type } returns SearchEngine.Type.CUSTOM
+
+ MetricsUtils.recordSearchMetrics(
+ engine,
+ false,
+ MetricsUtils.Source.SUGGESTION,
+ )
+
+ assertNotNull(Metrics.searchCount["custom.suggestion"].testGetValue())
+ }
+
+ @Test
+ fun `given a CUSTOM engine, when the search source is a TOPSITE the proper labeled metric is recorded`() {
+ assertNull(Metrics.searchCount["custom.topsite"].testGetValue())
+
+ val engine: SearchEngine = mockk(relaxed = true)
+
+ every { engine.id } returns ENGINE_SOURCE_IDENTIFIER
+ every { engine.type } returns SearchEngine.Type.CUSTOM
+
+ MetricsUtils.recordSearchMetrics(
+ engine,
+ false,
+ MetricsUtils.Source.TOPSITE,
+ )
+
+ assertNotNull(Metrics.searchCount["custom.topsite"].testGetValue())
+ }
+
+ @Test
+ fun `given a CUSTOM engine, when the search source is a WIDGET the proper labeled metric is recorded`() {
+ assertNull(Metrics.searchCount["custom.widget"].testGetValue())
+
+ val engine: SearchEngine = mockk(relaxed = true)
+
+ every { engine.id } returns ENGINE_SOURCE_IDENTIFIER
+ every { engine.type } returns SearchEngine.Type.CUSTOM
+
+ MetricsUtils.recordSearchMetrics(
+ engine,
+ false,
+ MetricsUtils.Source.WIDGET,
+ )
+
+ assertNotNull(Metrics.searchCount["custom.widget"].testGetValue())
+ }
+
+ @Test
+ fun `given a BUNDLED engine, when the search source is an ACTION the proper labeled metric is recorded`() {
+ assertNull(Metrics.searchCount["$ENGINE_SOURCE_IDENTIFIER.action"].testGetValue())
+
+ val engine: SearchEngine = mockk(relaxed = true)
+
+ every { engine.id } returns ENGINE_SOURCE_IDENTIFIER
+ every { engine.type } returns SearchEngine.Type.BUNDLED
+
+ MetricsUtils.recordSearchMetrics(
+ engine,
+ false,
+ MetricsUtils.Source.ACTION,
+ )
+
+ assertNotNull(Metrics.searchCount["$ENGINE_SOURCE_IDENTIFIER.action"].testGetValue())
+ }
+
+ @Test
+ fun `given a BUNDLED engine, when the search source is a TOPSITE the proper labeled metric is recorded`() {
+ assertNull(Metrics.searchCount["$ENGINE_SOURCE_IDENTIFIER.topsite"].testGetValue())
+
+ val engine: SearchEngine = mockk(relaxed = true)
+
+ every { engine.id } returns ENGINE_SOURCE_IDENTIFIER
+ every { engine.type } returns SearchEngine.Type.BUNDLED
+
+ MetricsUtils.recordSearchMetrics(
+ engine,
+ false,
+ MetricsUtils.Source.TOPSITE,
+ )
+
+ assertNotNull(Metrics.searchCount["$ENGINE_SOURCE_IDENTIFIER.topsite"].testGetValue())
+ }
+
+ @Test
+ fun `given a BUNDLED engine, when the search source is a SHORTCUT the proper labeled metric is recorded`() {
+ assertNull(Metrics.searchCount["$ENGINE_SOURCE_IDENTIFIER.shortcut"].testGetValue())
+
+ val engine: SearchEngine = mockk(relaxed = true)
+
+ every { engine.id } returns ENGINE_SOURCE_IDENTIFIER
+ every { engine.type } returns SearchEngine.Type.BUNDLED
+
+ MetricsUtils.recordSearchMetrics(
+ engine,
+ false,
+ MetricsUtils.Source.SHORTCUT,
+ )
+
+ assertNotNull(Metrics.searchCount["$ENGINE_SOURCE_IDENTIFIER.shortcut"].testGetValue())
+ }
+
+ @Test
+ fun `given a BUNDLED engine, when the search source is a SUGGESTION the proper labeled metric is recorded`() {
+ assertNull(Metrics.searchCount["$ENGINE_SOURCE_IDENTIFIER.suggestion"].testGetValue())
+
+ val engine: SearchEngine = mockk(relaxed = true)
+
+ every { engine.id } returns ENGINE_SOURCE_IDENTIFIER
+ every { engine.type } returns SearchEngine.Type.BUNDLED
+
+ MetricsUtils.recordSearchMetrics(
+ engine,
+ false,
+ MetricsUtils.Source.SUGGESTION,
+ )
+
+ assertNotNull(Metrics.searchCount["$ENGINE_SOURCE_IDENTIFIER.suggestion"].testGetValue())
+ }
+
+ @Test
+ fun `given a BUNDLED engine, when the search source is a WIDGET the proper labeled metric is recorded`() {
+ assertNull(Metrics.searchCount["$ENGINE_SOURCE_IDENTIFIER.widget"].testGetValue())
+
+ val engine: SearchEngine = mockk(relaxed = true)
+
+ every { engine.id } returns ENGINE_SOURCE_IDENTIFIER
+ every { engine.type } returns SearchEngine.Type.BUNDLED
+
+ MetricsUtils.recordSearchMetrics(
+ engine,
+ false,
+ MetricsUtils.Source.WIDGET,
+ )
+
+ assertNotNull(Metrics.searchCount["$ENGINE_SOURCE_IDENTIFIER.widget"].testGetValue())
+ }
+
+ @Test
+ fun `given a BUNDLED engine with an uppercase id, when recording a new search with that engine then record using lowercase`() {
+ val searchEngineId = "Uppercase-Id"
+ assertNull(Metrics.searchCount["$searchEngineId.widget"].testGetValue())
+
+ val engine: SearchEngine = mockk(relaxed = true)
+
+ every { engine.id } returns searchEngineId
+ every { engine.type } returns SearchEngine.Type.BUNDLED
+
+ MetricsUtils.recordSearchMetrics(
+ engine,
+ false,
+ MetricsUtils.Source.WIDGET,
+ )
+
+ assertNotNull(Metrics.searchCount["${searchEngineId.lowercase()}.widget"].testGetValue())
+ }
+
+ @Test
+ fun `given a DEFAULT engine, when the search source is a WIDGET the proper labeled metric is recorded`() {
+ assertNull(Events.performedSearch.testGetValue())
+
+ val engine: SearchEngine = mockk(relaxed = true)
+
+ every { engine.id } returns ENGINE_SOURCE_IDENTIFIER
+ every { engine.type } returns SearchEngine.Type.BUNDLED
+
+ MetricsUtils.recordSearchMetrics(
+ engine,
+ true,
+ MetricsUtils.Source.WIDGET,
+ )
+
+ assertNotNull(Events.performedSearch.testGetValue())
+ val snapshot = Events.performedSearch.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("default.widget", snapshot.single().extra?.getValue("source"))
+ }
+
+ @Test
+ fun `given a NON DEFAULT engine, when the search source is a WIDGET the proper labeled metric is recorded`() {
+ assertNull(Events.performedSearch.testGetValue())
+
+ val engine: SearchEngine = mockk(relaxed = true)
+
+ every { engine.id } returns ENGINE_SOURCE_IDENTIFIER
+ every { engine.type } returns SearchEngine.Type.BUNDLED
+
+ MetricsUtils.recordSearchMetrics(
+ engine,
+ false,
+ MetricsUtils.Source.WIDGET,
+ )
+
+ assertNotNull(Events.performedSearch.testGetValue())
+ val snapshot = Events.performedSearch.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("shortcut.widget", snapshot.single().extra?.getValue("source"))
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/settings/CounterPreferenceTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/settings/CounterPreferenceTest.kt
new file mode 100644
index 0000000000..6a9f815b98
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/settings/CounterPreferenceTest.kt
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.settings
+
+import android.content.SharedPreferences
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.verify
+import mozilla.components.support.ktx.android.content.PreferencesHolder
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class CounterPreferenceTest {
+
+ @MockK private lateinit var prefs: SharedPreferences
+
+ @MockK private lateinit var editor: SharedPreferences.Editor
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ every { prefs.getInt("key", 0) } returns 0
+ every { prefs.edit() } returns editor
+ every { editor.putInt("key", any()) } returns editor
+ every { editor.apply() } just Runs
+ }
+
+ @Test
+ fun `update value after increment`() {
+ val holder = CounterHolder()
+
+ assertEquals(0, holder.property.value)
+ holder.property.increment()
+
+ verify { editor.putInt("key", 1) }
+ }
+
+ @Test
+ fun `check if value is under max count`() {
+ val holder = CounterHolder(maxCount = 2)
+
+ every { prefs.getInt("key", 0) } returns 0
+ assertEquals(0, holder.property.value)
+ assertTrue(holder.property.underMaxCount())
+
+ every { prefs.getInt("key", 0) } returns 2
+ assertEquals(2, holder.property.value)
+ assertFalse(holder.property.underMaxCount())
+ }
+
+ private inner class CounterHolder(maxCount: Int = -1) : PreferencesHolder {
+ override val preferences = prefs
+
+ val property = counterPreference("key", maxCount)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/settings/FeatureFlagPreferenceTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/settings/FeatureFlagPreferenceTest.kt
new file mode 100644
index 0000000000..6af4a9ab4f
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/settings/FeatureFlagPreferenceTest.kt
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.settings
+
+import android.content.SharedPreferences
+import io.mockk.Called
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.verify
+import mozilla.components.support.ktx.android.content.PreferencesHolder
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class FeatureFlagPreferenceTest {
+
+ @MockK private lateinit var prefs: SharedPreferences
+
+ @MockK private lateinit var editor: SharedPreferences.Editor
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ every { prefs.getBoolean("key", false) } returns true
+ every { prefs.edit() } returns editor
+ every { editor.putBoolean("key", any()) } returns editor
+ every { editor.apply() } just Runs
+ }
+
+ @Test
+ fun `acts like boolean preference if feature flag is true`() {
+ val holder = FeatureFlagHolder(featureFlag = true)
+
+ assertTrue(holder.property)
+ verify { prefs.getBoolean("key", false) }
+
+ holder.property = false
+ verify { editor.putBoolean("key", false) }
+ }
+
+ @Test
+ fun `no-op if feature flag is false`() {
+ val holder = FeatureFlagHolder(featureFlag = false)
+
+ assertFalse(holder.property)
+ holder.property = true
+ holder.property = false
+
+ verify { prefs wasNot Called }
+ verify { editor wasNot Called }
+ }
+
+ private inner class FeatureFlagHolder(featureFlag: Boolean) : PreferencesHolder {
+ override val preferences = prefs
+
+ var property by featureFlagPreference(
+ "key",
+ default = false,
+ featureFlag = featureFlag,
+ )
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/settings/sitepermissions/ExtensionsTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/settings/sitepermissions/ExtensionsTest.kt
new file mode 100644
index 0000000000..a0550ff8f1
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/settings/sitepermissions/ExtensionsTest.kt
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.settings.sitepermissions
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.support.ktx.kotlin.getOrigin
+import org.junit.Test
+import org.mozilla.fenix.components.Components
+import org.mozilla.fenix.settings.sitepermissions.tryReloadTabBy
+
+class ExtensionsTest {
+
+ @Test
+ fun `tryReloadTabBy reloads latest tab matching origin`() {
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab(id = "1", url = "https://www.mozilla.org/1", lastAccess = 1),
+ createTab(id = "2", url = "https://www.mozilla.org/2", lastAccess = 2),
+ createTab(id = "3", url = "https://www.firefox.com"),
+ ),
+ ),
+ )
+
+ val components: Components = mockk(relaxed = true)
+ every { components.core.store } returns store
+
+ components.tryReloadTabBy("https://www.getpocket.com".getOrigin()!!)
+ verify(exactly = 0) { components.useCases.sessionUseCases.reload(any()) }
+
+ components.tryReloadTabBy("https://www.mozilla.org".getOrigin()!!)
+ verify { components.useCases.sessionUseCases.reload("2") }
+
+ components.tryReloadTabBy("https://www.firefox.com".getOrigin()!!)
+ verify { components.useCases.sessionUseCases.reload("3") }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/BrowserToolbarCFRPresenterTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/BrowserToolbarCFRPresenterTest.kt
new file mode 100644
index 0000000000..c5bfe6124e
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/BrowserToolbarCFRPresenterTest.kt
@@ -0,0 +1,534 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.toolbar
+
+import android.content.Context
+import android.view.View
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.cancel
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.action.CookieBannerAction
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.CustomTabSessionState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.createCustomTab
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.browser.toolbar.BrowserToolbar
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.telemetry.glean.testing.GleanTestRule
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.TrackingProtection
+import org.mozilla.fenix.R
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.shopping.ShoppingExperienceFeature
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class BrowserToolbarCFRPresenterTest {
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @Test
+ fun `GIVEN the TCP CFR should be shown for a custom tab WHEN the custom tab is fully loaded THEN the TCP CFR is shown`() {
+ val customTab = createCustomTab(url = "")
+ val browserStore = createBrowserStore(customTab = customTab)
+ val presenter = createPresenterThatShowsCFRs(
+ browserStore = browserStore,
+ sessionId = customTab.id,
+ )
+
+ presenter.start()
+
+ assertNotNull(presenter.scope)
+
+ browserStore.dispatch(ContentAction.UpdateProgressAction(customTab.id, 0)).joinBlocking()
+ verify(exactly = 0) { presenter.showTcpCfr() }
+
+ browserStore.dispatch(ContentAction.UpdateProgressAction(customTab.id, 33)).joinBlocking()
+ verify(exactly = 0) { presenter.showTcpCfr() }
+
+ browserStore.dispatch(ContentAction.UpdateProgressAction(customTab.id, 100)).joinBlocking()
+ verify { presenter.showTcpCfr() }
+ }
+
+ @Test
+ fun `GIVEN the cookie banners handling CFR should be shown for a custom tab WHEN the custom tab is fully loaded THEN the TCP CFR is shown`() {
+ val privateTab = createTab(url = "", private = true)
+ val browserStore = createBrowserStore(tab = privateTab, selectedTabId = privateTab.id)
+ val settings: Settings = mockk(relaxed = true) {
+ every { shouldShowTotalCookieProtectionCFR } returns false
+ every { shouldShowReviewQualityCheckCFR } returns false
+ every { reviewQualityCheckOptInTimeInMillis } returns System.currentTimeMillis()
+ every { shouldShowEraseActionCFR } returns false
+ every { shouldShowCookieBannersCFR } returns true
+ every { shouldUseCookieBannerPrivateMode } returns true
+ every { reviewQualityCheckCfrDisplayTimeInMillis } returns 0L
+ }
+ val presenter = createPresenter(
+ isPrivate = true,
+ browserStore = browserStore,
+ settings = settings,
+ )
+
+ presenter.start()
+
+ assertNotNull(presenter.scope)
+
+ browserStore.dispatch(
+ CookieBannerAction.UpdateStatusAction(
+ privateTab.id,
+ EngineSession.CookieBannerHandlingStatus.HANDLED,
+ ),
+ ).joinBlocking()
+
+ verify { presenter.showCookieBannersCFR() }
+ verify { settings.shouldShowCookieBannersCFR = false }
+ }
+
+ @Test
+ fun `GIVEN the TCP CFR should be shown WHEN the current normal tab is fully loaded THEN the TCP CFR is shown`() {
+ val normalTab = createTab(url = "", private = false)
+ val browserStore = createBrowserStore(
+ tab = normalTab,
+ selectedTabId = normalTab.id,
+ )
+ val presenter = createPresenterThatShowsCFRs(browserStore = browserStore)
+
+ presenter.start()
+
+ assertNotNull(presenter.scope)
+
+ browserStore.dispatch(ContentAction.UpdateProgressAction(normalTab.id, 1)).joinBlocking()
+ verify(exactly = 0) { presenter.showTcpCfr() }
+
+ browserStore.dispatch(ContentAction.UpdateProgressAction(normalTab.id, 98)).joinBlocking()
+ verify(exactly = 0) { presenter.showTcpCfr() }
+
+ browserStore.dispatch(ContentAction.UpdateProgressAction(normalTab.id, 100)).joinBlocking()
+ verify { presenter.showTcpCfr() }
+ }
+
+ @Test
+ fun `GIVEN the TCP CFR should be shown WHEN the current private tab is fully loaded THEN the TCP CFR is shown`() {
+ val privateTab = createTab(url = "", private = true)
+ val browserStore = createBrowserStore(
+ tab = privateTab,
+ selectedTabId = privateTab.id,
+ )
+ val presenter = createPresenterThatShowsCFRs(browserStore = browserStore)
+
+ presenter.start()
+
+ assertNotNull(presenter.scope)
+
+ browserStore.dispatch(ContentAction.UpdateProgressAction(privateTab.id, 14)).joinBlocking()
+ verify(exactly = 0) { presenter.showTcpCfr() }
+
+ browserStore.dispatch(ContentAction.UpdateProgressAction(privateTab.id, 99)).joinBlocking()
+ verify(exactly = 0) { presenter.showTcpCfr() }
+
+ browserStore.dispatch(ContentAction.UpdateProgressAction(privateTab.id, 100)).joinBlocking()
+ verify { presenter.showTcpCfr() }
+ }
+
+ @Test
+ fun `GIVEN the TCP CFR should be shown WHEN the current tab is fully loaded THEN the TCP CFR is only shown once`() {
+ val tab = createTab(url = "")
+ val browserStore = createBrowserStore(
+ tab = tab,
+ selectedTabId = tab.id,
+ )
+ val presenter = createPresenterThatShowsCFRs(browserStore = browserStore)
+
+ presenter.start()
+
+ assertNotNull(presenter.scope)
+
+ browserStore.dispatch(ContentAction.UpdateProgressAction(tab.id, 99)).joinBlocking()
+ browserStore.dispatch(ContentAction.UpdateProgressAction(tab.id, 100)).joinBlocking()
+ browserStore.dispatch(ContentAction.UpdateProgressAction(tab.id, 100)).joinBlocking()
+ browserStore.dispatch(ContentAction.UpdateProgressAction(tab.id, 100)).joinBlocking()
+ verify(exactly = 1) { presenter.showTcpCfr() }
+ }
+
+ @Test
+ fun `GIVEN the Erase CFR should be shown WHEN in private mode and the current tab is fully loaded THEN the Erase CFR is only shown once`() {
+ val tab = createTab(url = "", private = true)
+
+ val browserStore = createBrowserStore(
+ tab = tab,
+ selectedTabId = tab.id,
+ )
+
+ val presenter = createPresenterThatShowsCFRs(
+ browserStore = browserStore,
+ settings = mockk {
+ every { shouldShowEraseActionCFR } returns true
+ },
+ isPrivate = true,
+ )
+
+ presenter.start()
+
+ assertNotNull(presenter.scope)
+
+ browserStore.dispatch(ContentAction.UpdateProgressAction(tab.id, 99)).joinBlocking()
+ browserStore.dispatch(ContentAction.UpdateProgressAction(tab.id, 100)).joinBlocking()
+ browserStore.dispatch(ContentAction.UpdateProgressAction(tab.id, 100)).joinBlocking()
+ browserStore.dispatch(ContentAction.UpdateProgressAction(tab.id, 100)).joinBlocking()
+ verify { presenter.showEraseCfr() }
+ }
+
+ @Test
+ fun `GIVEN no CFR shown WHEN the feature starts THEN don't observe the store for updates`() {
+ val presenter = createPresenter(
+ settings = mockk {
+ every { shouldShowTotalCookieProtectionCFR } returns false
+ every { shouldShowReviewQualityCheckCFR } returns false
+ every { shouldShowEraseActionCFR } returns false
+ },
+ )
+
+ presenter.start()
+
+ assertNull(presenter.scope)
+ }
+
+ @Test
+ fun `GIVEN the store is observed for updates WHEN the presenter is stopped THEN stop observing the store`() {
+ val scope: CoroutineScope = mockk {
+ every { cancel() } just Runs
+ }
+ val presenter = createPresenter()
+ presenter.scope = scope
+
+ presenter.stop()
+
+ verify { scope.cancel() }
+ }
+
+ @Test
+ fun `WHEN the TCP CFR is to be shown THEN instantiate a new one and remember show it again unless explicitly dismissed`() {
+ val settings: Settings = mockk(relaxed = true)
+ val presenter = createPresenter(
+ anchor = mockk(relaxed = true),
+ settings = settings,
+ )
+
+ presenter.showTcpCfr()
+
+ verify(exactly = 0) { settings.shouldShowTotalCookieProtectionCFR = false }
+ assertNotNull(presenter.popup)
+ }
+
+ @Test
+ fun `WHEN the TCP CFR is shown THEN log telemetry`() {
+ val presenter = createPresenter(
+ anchor = mockk(relaxed = true),
+ )
+
+ assertNull(TrackingProtection.tcpCfrShown.testGetValue())
+
+ presenter.showTcpCfr()
+
+ assertNotNull(TrackingProtection.tcpCfrShown.testGetValue())
+ }
+
+ @Test
+ fun `GIVEN the current tab is showing a product page WHEN the tab is not loading THEN the CFR is shown`() {
+ val tab = createTab(url = "")
+ val browserStore = createBrowserStore(
+ tab = tab,
+ selectedTabId = tab.id,
+ )
+ val presenter = createPresenter(
+ browserStore = browserStore,
+ settings = mockk {
+ every { shouldShowTotalCookieProtectionCFR } returns false
+ every { shouldShowReviewQualityCheckCFR } returns true
+ every { reviewQualityCheckOptInTimeInMillis } returns System.currentTimeMillis()
+ every { shouldShowEraseActionCFR } returns false
+ every { reviewQualityCheckCfrDisplayTimeInMillis } returns 0L
+ },
+ )
+ every { presenter.showShoppingCFR(any()) } just Runs
+
+ presenter.start()
+
+ assertNotNull(presenter.scope)
+
+ browserStore.dispatch(ContentAction.UpdateLoadingStateAction(tab.id, true)).joinBlocking()
+ verify(exactly = 0) { presenter.showShoppingCFR(eq(false)) }
+
+ browserStore.dispatch(ContentAction.UpdateProductUrlStateAction(tab.id, true)).joinBlocking()
+ verify(exactly = 0) { presenter.showShoppingCFR(eq(false)) }
+
+ browserStore.dispatch(ContentAction.UpdateProgressAction(tab.id, 100)).joinBlocking()
+ verify(exactly = 0) { presenter.showShoppingCFR(eq(false)) }
+
+ browserStore.dispatch(ContentAction.UpdateLoadingStateAction(tab.id, false)).joinBlocking()
+ verify { presenter.showShoppingCFR(eq(false)) }
+ }
+
+ @Test
+ fun `GIVEN the current tab is showing a product page WHEN the tab is not loading AND another CFR is shown THEN the shopping CFR is not shown`() {
+ val tab = createTab(url = "")
+ val browserStore = createBrowserStore(
+ tab = tab,
+ selectedTabId = tab.id,
+ )
+ val presenter = createPresenter(
+ browserStore = browserStore,
+ settings = mockk {
+ every { shouldShowTotalCookieProtectionCFR } returns false
+ every { shouldShowReviewQualityCheckCFR } returns true
+ every { reviewQualityCheckOptInTimeInMillis } returns System.currentTimeMillis()
+ every { shouldShowEraseActionCFR } returns false
+ every { reviewQualityCheckCfrDisplayTimeInMillis } returns 0L
+ },
+ )
+ every { presenter.popup } returns mockk()
+ every { presenter.showShoppingCFR(any()) } just Runs
+
+ presenter.start()
+
+ assertNotNull(presenter.scope)
+
+ browserStore.dispatch(ContentAction.UpdateProductUrlStateAction(tab.id, true)).joinBlocking()
+ verify(exactly = 0) { presenter.showShoppingCFR(eq(false)) }
+
+ browserStore.dispatch(ContentAction.UpdateProgressAction(tab.id, 100)).joinBlocking()
+ verify(exactly = 0) { presenter.showShoppingCFR(eq(false)) }
+ }
+
+ @Test
+ fun `GIVEN the user opted in the shopping feature AND the opted in shopping CFR should be shown WHEN the tab finishes loading THEN the CFR is shown`() {
+ val tab = createTab(url = "")
+ val browserStore = createBrowserStore(
+ tab = tab,
+ selectedTabId = tab.id,
+ )
+
+ val presenter = createPresenter(
+ settings = mockk {
+ every { shouldShowTotalCookieProtectionCFR } returns false
+ every { shouldShowReviewQualityCheckCFR } returns true
+ every { shouldShowEraseActionCFR } returns false
+ every { reviewQualityCheckOptInTimeInMillis } returns System.currentTimeMillis() - Settings.TWO_DAYS_MS
+ every { reviewQualityCheckCfrDisplayTimeInMillis } returns System.currentTimeMillis() - Settings.TWO_DAYS_MS
+ },
+ browserStore = browserStore,
+ )
+ every { presenter.showShoppingCFR(any()) } just Runs
+
+ presenter.start()
+
+ assertNotNull(presenter.scope)
+
+ browserStore.dispatch(ContentAction.UpdateLoadingStateAction(tab.id, true)).joinBlocking()
+ verify(exactly = 0) { presenter.showShoppingCFR(eq(true)) }
+
+ browserStore.dispatch(ContentAction.UpdateProductUrlStateAction(tab.id, true)).joinBlocking()
+ verify(exactly = 0) { presenter.showShoppingCFR(eq(true)) }
+
+ browserStore.dispatch(ContentAction.UpdateProgressAction(tab.id, 100)).joinBlocking()
+ verify(exactly = 0) { presenter.showShoppingCFR(eq(true)) }
+
+ browserStore.dispatch(ContentAction.UpdateLoadingStateAction(tab.id, false)).joinBlocking()
+ verify { presenter.showShoppingCFR(eq(true)) }
+ }
+
+ @Test
+ fun `GIVEN the user opted in the shopping feature AND the opted in shopping CFR should be shown WHEN opening a loaded product page THEN the CFR is shown`() {
+ val tab1 = createTab(url = "", id = "tab1")
+ val tab2 = createTab(url = "", id = "tab2")
+ val browserStore = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(tab1, tab2),
+ selectedTabId = tab2.id,
+ ),
+ )
+
+ val presenter = createPresenter(
+ settings = mockk {
+ every { shouldShowTotalCookieProtectionCFR } returns false
+ every { shouldShowReviewQualityCheckCFR } returns true
+ every { shouldShowEraseActionCFR } returns false
+ every { reviewQualityCheckOptInTimeInMillis } returns System.currentTimeMillis() - Settings.TWO_DAYS_MS
+ every { reviewQualityCheckCfrDisplayTimeInMillis } returns System.currentTimeMillis() - Settings.TWO_DAYS_MS
+ },
+ browserStore = browserStore,
+ )
+ every { presenter.showShoppingCFR(any()) } just Runs
+
+ presenter.start()
+
+ assertNotNull(presenter.scope)
+
+ browserStore.dispatch(ContentAction.UpdateProductUrlStateAction(tab1.id, true)).joinBlocking()
+ browserStore.dispatch(ContentAction.UpdateProgressAction(tab1.id, 100)).joinBlocking()
+ verify(exactly = 0) { presenter.showShoppingCFR(any()) }
+
+ browserStore.dispatch(TabListAction.SelectTabAction(tab1.id)).joinBlocking()
+ verify(exactly = 1) { presenter.showShoppingCFR(true) }
+ }
+
+ @Test
+ fun `GIVEN the first CFR was displayed less than 12h ago AND the user did not opt in to the shopping feature WHEN opening a loaded product page THEN no shopping CFR is shown`() {
+ val tab1 = createTab(url = "", id = "tab1")
+ val browserStore = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(tab1),
+ selectedTabId = tab1.id,
+ ),
+ )
+
+ val presenter = createPresenter(
+ settings = mockk {
+ every { shouldShowTotalCookieProtectionCFR } returns false
+ every { shouldShowReviewQualityCheckCFR } returns true
+ every { shouldShowEraseActionCFR } returns false
+ every { reviewQualityCheckOptInTimeInMillis } returns 0L
+ every { reviewQualityCheckCfrDisplayTimeInMillis } returns System.currentTimeMillis() - (11 * 60 * 60 * 1000L)
+ },
+ browserStore = browserStore,
+ )
+ every { presenter.showShoppingCFR(any()) } just Runs
+
+ presenter.start()
+
+ assertNull(presenter.scope)
+ }
+
+ @Test
+ fun `GIVEN the first CFR was displayed 12h ago AND the user did not opt in to the shopping feature WHEN opening a loaded product page THEN the first shopping CFR is shown`() {
+ val tab1 = createTab(url = "", id = "tab1")
+ val tab2 = createTab(url = "", id = "tab2")
+ val browserStore = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(tab1, tab2),
+ selectedTabId = tab2.id,
+ ),
+ )
+
+ val presenter = createPresenter(
+ settings = mockk {
+ every { shouldShowTotalCookieProtectionCFR } returns false
+ every { shouldShowReviewQualityCheckCFR } returns true
+ every { shouldShowEraseActionCFR } returns false
+ every { reviewQualityCheckOptInTimeInMillis } returns 0L
+ every { reviewQualityCheckCfrDisplayTimeInMillis } returns System.currentTimeMillis() - Settings.TWELVE_HOURS_MS
+ },
+ browserStore = browserStore,
+ )
+ every { presenter.showShoppingCFR(any()) } just Runs
+
+ presenter.start()
+
+ assertNotNull(presenter.scope)
+
+ browserStore.dispatch(ContentAction.UpdateProductUrlStateAction(tab1.id, true)).joinBlocking()
+ browserStore.dispatch(ContentAction.UpdateProgressAction(tab1.id, 100)).joinBlocking()
+ verify(exactly = 0) { presenter.showShoppingCFR(any()) }
+
+ browserStore.dispatch(TabListAction.SelectTabAction(tab1.id)).joinBlocking()
+ verify(exactly = 1) { presenter.showShoppingCFR(false) }
+ }
+
+ /**
+ * Creates and return a [spyk] of a [BrowserToolbarCFRPresenter] that can handle actually showing CFRs.
+ */
+ private fun createPresenterThatShowsCFRs(
+ context: Context = mockk(),
+ anchor: View = mockk(),
+ browserStore: BrowserStore = mockk(),
+ settings: Settings = mockk {
+ every { shouldShowTotalCookieProtectionCFR } returns true
+ every { openTabsCount } returns 5
+ every { shouldShowReviewQualityCheckCFR } returns false
+ every { shouldShowEraseActionCFR } returns false
+ },
+ toolbar: BrowserToolbar = mockk(),
+ isPrivate: Boolean = false,
+ sessionId: String? = null,
+ ) = spyk(createPresenter(context, anchor, browserStore, settings, toolbar, sessionId, isPrivate)) {
+ every { showTcpCfr() } just Runs
+ every { showShoppingCFR(any()) } just Runs
+ every { showEraseCfr() } just Runs
+ }
+
+ /**
+ * Create and return a [BrowserToolbarCFRPresenter] with all constructor properties mocked by default.
+ * Calls to show a CFR will fail. If this behavior is needed to work use [createPresenterThatShowsCFRs].
+ */
+ private fun createPresenter(
+ context: Context = mockk {
+ every { getString(R.string.tcp_cfr_message) } returns "Test"
+ every { getColor(any()) } returns 0
+ every { getString(R.string.pref_key_should_show_review_quality_cfr) } returns "test"
+ },
+ anchor: View = mockk(relaxed = true),
+ browserStore: BrowserStore = mockk(),
+ settings: Settings = mockk(relaxed = true) {
+ every { shouldShowTotalCookieProtectionCFR } returns true
+ every { shouldShowEraseActionCFR } returns true
+ every { openTabsCount } returns 5
+ every { shouldShowCookieBannersCFR } returns true
+ every { shouldShowReviewQualityCheckCFR } returns true
+ },
+ toolbar: BrowserToolbar = mockk {
+ every { findViewById<View>(R.id.mozac_browser_toolbar_security_indicator) } returns anchor
+ every { findViewById<View>(R.id.mozac_browser_toolbar_page_actions) } returns anchor
+ every { findViewById<View>(R.id.mozac_browser_toolbar_navigation_actions) } returns anchor
+ },
+ sessionId: String? = null,
+ isPrivate: Boolean = false,
+ shoppingExperienceFeature: ShoppingExperienceFeature = mockk {
+ every { isEnabled } returns true
+ },
+ ) = spyk(
+ BrowserToolbarCFRPresenter(
+ context = context,
+ browserStore = browserStore,
+ settings = settings,
+ toolbar = toolbar,
+ sessionId = sessionId,
+ isPrivate = isPrivate,
+ onShoppingCfrActionClicked = {},
+ onShoppingCfrDisplayed = {},
+ shoppingExperienceFeature = shoppingExperienceFeature,
+ ),
+ )
+
+ private fun createBrowserStore(
+ tab: TabSessionState? = null,
+ customTab: CustomTabSessionState? = null,
+ selectedTabId: String? = null,
+ ) = BrowserStore(
+ initialState = BrowserState(
+ tabs = if (tab != null) listOf(tab) else listOf(),
+ customTabs = if (customTab != null) listOf(customTab) else listOf(),
+ selectedTabId = selectedTabId,
+ ),
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/BrowserToolbarViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/BrowserToolbarViewTest.kt
new file mode 100644
index 0000000000..a58f00130e
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/BrowserToolbarViewTest.kt
@@ -0,0 +1,287 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.toolbar
+
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import io.mockk.confirmVerified
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.browser.toolbar.BrowserToolbar
+import mozilla.components.lib.publicsuffixlist.PublicSuffixList
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.ui.widgets.behavior.EngineViewScrollingBehavior
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.utils.Settings
+import mozilla.components.ui.widgets.behavior.ViewPosition as MozacToolbarPosition
+
+@RunWith(FenixRobolectricTestRunner::class)
+class BrowserToolbarViewTest {
+ private lateinit var toolbarView: BrowserToolbarView
+ private lateinit var toolbar: BrowserToolbar
+ private lateinit var behavior: EngineViewScrollingBehavior
+ private lateinit var settings: Settings
+
+ @Before
+ fun setup() {
+ toolbar = BrowserToolbar(testContext)
+ toolbar.layoutParams = CoordinatorLayout.LayoutParams(100, 100)
+
+ settings = mockk(relaxed = true)
+ every { testContext.components.useCases } returns mockk(relaxed = true)
+ every { testContext.components.core } returns mockk(relaxed = true)
+ every { testContext.components.publicSuffixList } returns PublicSuffixList(testContext)
+ every { testContext.settings() } returns settings
+ toolbarView = BrowserToolbarView(
+ context = testContext,
+ settings = settings,
+ container = CoordinatorLayout(testContext),
+ interactor = mockk(),
+ customTabSession = mockk(relaxed = true),
+ lifecycleOwner = mockk(),
+ tabStripContent = {},
+ )
+
+ toolbarView.view = toolbar
+ behavior = spyk(EngineViewScrollingBehavior(testContext, null, MozacToolbarPosition.BOTTOM))
+ (toolbarView.layout.layoutParams as CoordinatorLayout.LayoutParams).behavior = behavior
+ }
+
+ @Test
+ fun `setToolbarBehavior(false) should setDynamicToolbarBehavior if no a11y, bottom toolbar is dynamic and the tab is not for a PWA or TWA`() {
+ val toolbarViewSpy = spyk(toolbarView)
+ every { settings.toolbarPosition } returns ToolbarPosition.BOTTOM
+ every { settings.isDynamicToolbarEnabled } returns true
+ every { toolbarViewSpy.isPwaTabOrTwaTab } returns false
+ every { settings.shouldUseFixedTopToolbar } returns false
+
+ toolbarViewSpy.setToolbarBehavior(false)
+
+ verify { toolbarViewSpy.setDynamicToolbarBehavior(MozacToolbarPosition.BOTTOM) }
+ }
+
+ @Test
+ fun `setToolbarBehavior(false) should expandToolbarAndMakeItFixed if bottom toolbar is not set as dynamic`() {
+ val toolbarViewSpy = spyk(toolbarView)
+ every { settings.toolbarPosition } returns ToolbarPosition.BOTTOM
+ every { settings.isDynamicToolbarEnabled } returns false
+ every { toolbarViewSpy.isPwaTabOrTwaTab } returns false
+ every { settings.shouldUseFixedTopToolbar } returns false
+
+ toolbarViewSpy.setToolbarBehavior(false)
+
+ verify { toolbarViewSpy.expandToolbarAndMakeItFixed() }
+ }
+
+ @Test
+ fun `setToolbarBehavior(false) should expandToolbarAndMakeItFixed if bottom toolbar is dynamic but the tab is for a PWA or TWA`() {
+ val toolbarViewSpy = spyk(toolbarView)
+ every { settings.toolbarPosition } returns ToolbarPosition.BOTTOM
+ every { settings.isDynamicToolbarEnabled } returns true
+ every { toolbarViewSpy.isPwaTabOrTwaTab } returns true
+ every { settings.shouldUseFixedTopToolbar } returns false
+
+ toolbarViewSpy.setToolbarBehavior(false)
+
+ verify { toolbarViewSpy.expandToolbarAndMakeItFixed() }
+ }
+
+ @Test
+ fun `setToolbarBehavior(false) should expandToolbarAndMakeItFixed if bottom toolbar is dynamic tab is not for a PWA or TWA but a11y is enabled`() {
+ val toolbarViewSpy = spyk(toolbarView)
+ every { settings.toolbarPosition } returns ToolbarPosition.BOTTOM
+ every { settings.isDynamicToolbarEnabled } returns true
+ every { toolbarViewSpy.isPwaTabOrTwaTab } returns false
+ every { settings.shouldUseFixedTopToolbar } returns true
+
+ toolbarViewSpy.setToolbarBehavior(false)
+
+ verify { toolbarViewSpy.expandToolbarAndMakeItFixed() }
+ }
+
+ @Test
+ fun `setToolbarBehavior(true) should expandToolbarAndMakeItFixed bottom toolbar is dynamic, the tab is not for a PWA or TWA and a11y is disabled`() {
+ // All intrinsic checks are met but the method was called with `shouldDisableScroll` = true
+
+ val toolbarViewSpy = spyk(toolbarView)
+ every { settings.toolbarPosition } returns ToolbarPosition.BOTTOM
+ every { settings.isDynamicToolbarEnabled } returns true
+ every { toolbarViewSpy.isPwaTabOrTwaTab } returns false
+ every { settings.shouldUseFixedTopToolbar } returns false
+
+ toolbarViewSpy.setToolbarBehavior(false)
+
+ verify { toolbarViewSpy.setDynamicToolbarBehavior(MozacToolbarPosition.BOTTOM) }
+ }
+
+ @Test
+ fun `setToolbarBehavior(true) should expandToolbarAndMakeItFixed if bottom toolbar is not set as dynamic`() {
+ val toolbarViewSpy = spyk(toolbarView)
+ every { settings.toolbarPosition } returns ToolbarPosition.BOTTOM
+ every { settings.isDynamicToolbarEnabled } returns false
+ every { toolbarViewSpy.isPwaTabOrTwaTab } returns false
+ every { settings.shouldUseFixedTopToolbar } returns false
+
+ toolbarViewSpy.setToolbarBehavior(false)
+
+ verify { toolbarViewSpy.expandToolbarAndMakeItFixed() }
+ }
+
+ @Test
+ fun `setToolbarBehavior(true) should expandToolbarAndMakeItFixed if bottom toolbar is dynamic but the tab is for a PWA or TWA`() {
+ val toolbarViewSpy = spyk(toolbarView)
+ every { settings.toolbarPosition } returns ToolbarPosition.BOTTOM
+ every { settings.isDynamicToolbarEnabled } returns true
+ every { toolbarViewSpy.isPwaTabOrTwaTab } returns true
+ every { settings.shouldUseFixedTopToolbar } returns false
+
+ toolbarViewSpy.setToolbarBehavior(false)
+
+ verify { toolbarViewSpy.expandToolbarAndMakeItFixed() }
+ }
+
+ @Test
+ fun `setToolbarBehavior(true) should expandToolbarAndMakeItFixed if bottom toolbar is dynamic, the tab is for a PWA or TWA and a11 is enabled`() {
+ val toolbarViewSpy = spyk(toolbarView)
+ every { settings.toolbarPosition } returns ToolbarPosition.BOTTOM
+ every { settings.isDynamicToolbarEnabled } returns true
+ every { toolbarViewSpy.isPwaTabOrTwaTab } returns false
+ every { settings.shouldUseFixedTopToolbar } returns true
+
+ toolbarViewSpy.setToolbarBehavior(false)
+
+ verify { toolbarViewSpy.expandToolbarAndMakeItFixed() }
+ }
+
+ @Test
+ fun `setToolbarBehavior(true) should expandToolbarAndMakeItFixed for top toolbar if shouldUseFixedTopToolbar`() {
+ val toolbarViewSpy = spyk(toolbarView)
+ every { settings.toolbarPosition } returns ToolbarPosition.TOP
+ every { settings.shouldUseFixedTopToolbar } returns true
+
+ toolbarViewSpy.setToolbarBehavior(true)
+
+ verify { toolbarViewSpy.expandToolbarAndMakeItFixed() }
+ }
+
+ @Test
+ fun `setToolbarBehavior(true) should expandToolbarAndMakeItFixed for top toolbar if it is not dynamic`() {
+ val toolbarViewSpy = spyk(toolbarView)
+ every { settings.toolbarPosition } returns ToolbarPosition.TOP
+ every { settings.isDynamicToolbarEnabled } returns false
+
+ toolbarViewSpy.setToolbarBehavior(true)
+
+ verify { toolbarViewSpy.expandToolbarAndMakeItFixed() }
+ }
+
+ @Test
+ fun `setToolbarBehavior(true) should expandToolbarAndMakeItFixed for top toolbar if shouldDisableScroll`() {
+ val toolbarViewSpy = spyk(toolbarView)
+ every { settings.toolbarPosition } returns ToolbarPosition.TOP
+
+ toolbarViewSpy.setToolbarBehavior(true)
+
+ verify { toolbarViewSpy.expandToolbarAndMakeItFixed() }
+ }
+
+ @Test
+ fun `setToolbarBehavior(false) should setDynamicToolbarBehavior for top toolbar`() {
+ val toolbarViewSpy = spyk(toolbarView)
+ every { settings.toolbarPosition } returns ToolbarPosition.TOP
+ every { settings.shouldUseFixedTopToolbar } returns true
+ every { settings.isDynamicToolbarEnabled } returns true
+
+ toolbarViewSpy.setToolbarBehavior(true)
+
+ verify { toolbarViewSpy.expandToolbarAndMakeItFixed() }
+ }
+
+ @Test
+ fun `expandToolbarAndMakeItFixed should expand the toolbar and and disable the dynamic behavior`() {
+ val toolbarViewSpy = spyk(toolbarView)
+
+ assertNotNull((toolbarView.layout.layoutParams as CoordinatorLayout.LayoutParams).behavior)
+
+ toolbarViewSpy.expandToolbarAndMakeItFixed()
+
+ verify { toolbarViewSpy.expand() }
+ assertNull((toolbarView.layout.layoutParams as CoordinatorLayout.LayoutParams).behavior)
+ }
+
+ @Test
+ fun `setDynamicToolbarBehavior should set a ViewHideOnScrollBehavior for the bottom toolbar`() {
+ val toolbarViewSpy = spyk(toolbarView)
+ (toolbar.layoutParams as CoordinatorLayout.LayoutParams).behavior = null
+
+ toolbarViewSpy.setDynamicToolbarBehavior(MozacToolbarPosition.BOTTOM)
+
+ assertNotNull((toolbarView.layout.layoutParams as CoordinatorLayout.LayoutParams).behavior)
+ }
+
+ @Test
+ fun `setDynamicToolbarBehavior should set a ViewHideOnScrollBehavior for the top toolbar`() {
+ val toolbarViewSpy = spyk(toolbarView)
+ (toolbar.layoutParams as CoordinatorLayout.LayoutParams).behavior = null
+
+ toolbarViewSpy.setDynamicToolbarBehavior(MozacToolbarPosition.TOP)
+
+ assertNotNull((toolbarView.layout.layoutParams as CoordinatorLayout.LayoutParams).behavior)
+ }
+
+ @Test
+ fun `expand should not do anything if isPwaTabOrTwaTab`() {
+ val toolbarViewSpy = spyk(toolbarView)
+ every { toolbarViewSpy.isPwaTabOrTwaTab } returns true
+
+ toolbarViewSpy.expand()
+
+ verify { toolbarViewSpy.expand() }
+ verify { toolbarViewSpy.isPwaTabOrTwaTab }
+ // verify that no other interactions than the expected ones took place
+ confirmVerified(toolbarViewSpy)
+ }
+
+ @Test
+ fun `expand should call forceExpand if not isPwaTabOrTwaTab`() {
+ val toolbarViewSpy = spyk(toolbarView)
+ every { toolbarViewSpy.isPwaTabOrTwaTab } returns false
+
+ toolbarViewSpy.expand()
+
+ verify { behavior.forceExpand(toolbarView.layout) }
+ }
+
+ @Test
+ fun `collapse should not do anything if isPwaTabOrTwaTab`() {
+ val toolbarViewSpy = spyk(toolbarView)
+ every { toolbarViewSpy.isPwaTabOrTwaTab } returns true
+
+ toolbarViewSpy.collapse()
+
+ verify { toolbarViewSpy.collapse() }
+ verify { toolbarViewSpy.isPwaTabOrTwaTab }
+ // verify that no other interactions than the expected ones took place
+ confirmVerified(toolbarViewSpy)
+ }
+
+ @Test
+ fun `collapse should call forceExpand if not isPwaTabOrTwaTab`() {
+ val toolbarViewSpy = spyk(toolbarView)
+ every { toolbarViewSpy.isPwaTabOrTwaTab } returns false
+
+ toolbarViewSpy.collapse()
+
+ verify { behavior.forceCollapse(toolbarView.layout) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/DefaultBrowserToolbarControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/DefaultBrowserToolbarControllerTest.kt
new file mode 100644
index 0000000000..8b1819ac1c
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/DefaultBrowserToolbarControllerTest.kt
@@ -0,0 +1,485 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.toolbar
+
+import androidx.navigation.NavController
+import androidx.navigation.NavOptions
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.impl.annotations.RelaxedMockK
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.browser.state.action.BrowserAction
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.EngineView
+import mozilla.components.feature.search.SearchUseCases
+import mozilla.components.feature.session.SessionUseCases
+import mozilla.components.feature.tabs.TabsUseCases
+import mozilla.components.feature.top.sites.TopSitesUseCases
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.middleware.CaptureActionsMiddleware
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.ui.tabcounter.TabCounterMenu
+import mozilla.telemetry.glean.testing.GleanTestRule
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.Events
+import org.mozilla.fenix.GleanMetrics.ReaderMode
+import org.mozilla.fenix.GleanMetrics.Translations
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.R
+import org.mozilla.fenix.browser.BrowserAnimator
+import org.mozilla.fenix.browser.BrowserFragmentDirections
+import org.mozilla.fenix.browser.browsingmode.BrowsingMode
+import org.mozilla.fenix.browser.browsingmode.SimpleBrowsingModeManager
+import org.mozilla.fenix.browser.readermode.ReaderModeController
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.home.HomeFragment
+import org.mozilla.fenix.home.HomeScreenViewModel
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class DefaultBrowserToolbarControllerTest {
+
+ @RelaxedMockK
+ private lateinit var activity: HomeActivity
+
+ @MockK(relaxUnitFun = true)
+ private lateinit var navController: NavController
+
+ private var tabCounterClicked = false
+
+ @MockK(relaxUnitFun = true)
+ private lateinit var engineView: EngineView
+
+ @RelaxedMockK
+ private lateinit var searchUseCases: SearchUseCases
+
+ @RelaxedMockK
+ private lateinit var sessionUseCases: SessionUseCases
+
+ @RelaxedMockK
+ private lateinit var tabsUseCases: TabsUseCases
+
+ @RelaxedMockK
+ private lateinit var browserAnimator: BrowserAnimator
+
+ @RelaxedMockK
+ private lateinit var topSitesUseCase: TopSitesUseCases
+
+ @RelaxedMockK
+ private lateinit var readerModeController: ReaderModeController
+
+ @RelaxedMockK
+ private lateinit var homeViewModel: HomeScreenViewModel
+
+ private lateinit var store: BrowserStore
+ private val captureMiddleware = CaptureActionsMiddleware<BrowserState, BrowserAction>()
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+
+ every { activity.components.useCases.sessionUseCases } returns sessionUseCases
+ every { activity.components.useCases.searchUseCases } returns searchUseCases
+ every { activity.components.useCases.topSitesUseCase } returns topSitesUseCase
+ every { navController.currentDestination } returns mockk {
+ every { id } returns R.id.browserFragment
+ }
+
+ every {
+ browserAnimator.captureEngineViewAndDrawStatically(any(), any())
+ } answers {
+ secondArg<(Boolean) -> Unit>()(true)
+ }
+
+ tabCounterClicked = false
+
+ store = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab("https://www.mozilla.org", id = "1"),
+ ),
+ selectedTabId = "1",
+ ),
+ middleware = listOf(captureMiddleware),
+ )
+ }
+
+ @After
+ fun tearDown() {
+ captureMiddleware.reset()
+ }
+
+ @Test
+ fun handleBrowserToolbarPaste() {
+ val pastedText = "Mozilla"
+ val controller = createController()
+ controller.handleToolbarPaste(pastedText)
+
+ val directions = BrowserFragmentDirections.actionGlobalSearchDialog(
+ sessionId = "1",
+ pastedText = pastedText,
+ )
+
+ verify { navController.navigate(directions, any<NavOptions>()) }
+ }
+
+ @Test
+ fun handleBrowserToolbarPaste_useNewSearchExperience() {
+ val pastedText = "Mozilla"
+ val controller = createController()
+ controller.handleToolbarPaste(pastedText)
+
+ val directions = BrowserFragmentDirections.actionGlobalSearchDialog(
+ sessionId = "1",
+ pastedText = pastedText,
+ )
+
+ verify { navController.navigate(directions, any<NavOptions>()) }
+ }
+
+ @Test
+ fun handleBrowserToolbarPasteAndGoSearch() {
+ val pastedText = "Mozilla"
+
+ val controller = createController()
+ controller.handleToolbarPasteAndGo(pastedText)
+
+ verify {
+ searchUseCases.defaultSearch.invoke(pastedText, "1")
+ }
+
+ store.waitUntilIdle()
+
+ captureMiddleware.assertFirstAction(ContentAction.UpdateSearchTermsAction::class) { action ->
+ assertEquals("1", action.sessionId)
+ assertEquals(pastedText, action.searchTerms)
+ }
+ }
+
+ @Test
+ fun handleBrowserToolbarPasteAndGoUrl() {
+ val pastedText = "https://mozilla.org"
+
+ val controller = createController()
+ controller.handleToolbarPasteAndGo(pastedText)
+
+ verify {
+ sessionUseCases.loadUrl(pastedText)
+ }
+
+ store.waitUntilIdle()
+
+ captureMiddleware.assertFirstAction(ContentAction.UpdateSearchTermsAction::class) { action ->
+ assertEquals("1", action.sessionId)
+ assertEquals("", action.searchTerms)
+ }
+ }
+
+ @Test
+ fun handleTabCounterClick() {
+ assertFalse(tabCounterClicked)
+
+ val controller = createController()
+ controller.handleTabCounterClick()
+
+ assertTrue(tabCounterClicked)
+ }
+
+ @Test
+ fun `handle reader mode enabled`() {
+ val controller = createController()
+ assertNull(ReaderMode.opened.testGetValue())
+
+ controller.handleReaderModePressed(enabled = true)
+
+ verify { readerModeController.showReaderView() }
+ assertNotNull(ReaderMode.opened.testGetValue())
+ assertNull(ReaderMode.opened.testGetValue()!!.single().extra)
+ }
+
+ @Test
+ fun `handle reader mode disabled`() {
+ val controller = createController()
+ assertNull(ReaderMode.closed.testGetValue())
+
+ controller.handleReaderModePressed(enabled = false)
+
+ verify { readerModeController.hideReaderView() }
+ assertNotNull(ReaderMode.closed.testGetValue())
+ assertNull(ReaderMode.closed.testGetValue()!!.single().extra)
+ }
+
+ @Test
+ fun handleToolbarClick() {
+ val controller = createController()
+ assertNull(Events.searchBarTapped.testGetValue())
+
+ controller.handleToolbarClick()
+
+ val homeDirections = BrowserFragmentDirections.actionGlobalHome()
+ val searchDialogDirections = BrowserFragmentDirections.actionGlobalSearchDialog(
+ sessionId = "1",
+ )
+
+ assertNotNull(Events.searchBarTapped.testGetValue())
+ val snapshot = Events.searchBarTapped.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("BROWSER", snapshot.single().extra?.getValue("source"))
+
+ verify {
+ // shows the home screen "behind" the search dialog
+ navController.navigate(homeDirections)
+ navController.navigate(searchDialogDirections, any<NavOptions>())
+ }
+ }
+
+ @Test
+ fun handleToolbackClickWithSearchTerms() {
+ val searchResultsTab = createTab("https://google.com?q=mozilla+website", searchTerms = "mozilla website")
+ store.dispatch(TabListAction.AddTabAction(searchResultsTab, select = true)).joinBlocking()
+
+ assertNull(Events.searchBarTapped.testGetValue())
+
+ val controller = createController()
+ controller.handleToolbarClick()
+
+ val homeDirections = BrowserFragmentDirections.actionGlobalHome()
+ val searchDialogDirections = BrowserFragmentDirections.actionGlobalSearchDialog(
+ sessionId = searchResultsTab.id,
+ )
+
+ assertNotNull(Events.searchBarTapped.testGetValue())
+ val snapshot = Events.searchBarTapped.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("BROWSER", snapshot.single().extra?.getValue("source"))
+
+ // Does not show the home screen "behind" the search dialog if the current session has search terms.
+ verify(exactly = 0) {
+ navController.navigate(homeDirections)
+ }
+ verify {
+ navController.navigate(searchDialogDirections, any<NavOptions>())
+ }
+ }
+
+ @Test
+ fun handleToolbarCloseTabPressWithLastPrivateSession() {
+ val item = TabCounterMenu.Item.CloseTab
+
+ val controller = createController()
+ controller.handleTabCounterItemInteraction(item)
+ verify {
+ homeViewModel.sessionToDelete = "1"
+ navController.navigate(BrowserFragmentDirections.actionGlobalHome())
+ }
+ }
+
+ @Test
+ fun handleToolbarCloseTabPress() {
+ val item = TabCounterMenu.Item.CloseTab
+
+ val testTab = createTab("https://www.firefox.com")
+ store.dispatch(TabListAction.AddTabAction(testTab)).joinBlocking()
+ store.dispatch(TabListAction.SelectTabAction(testTab.id)).joinBlocking()
+
+ val controller = createController()
+ controller.handleTabCounterItemInteraction(item)
+ verify { tabsUseCases.removeTab(testTab.id, selectParentIfExists = true) }
+ }
+
+ @Test
+ fun handleToolbarNewTabPress() {
+ val browsingModeManager = SimpleBrowsingModeManager(BrowsingMode.Private)
+ val item = TabCounterMenu.Item.NewTab
+
+ every { activity.browsingModeManager } returns browsingModeManager
+ every { navController.navigate(BrowserFragmentDirections.actionGlobalHome(focusOnAddressBar = true)) } just Runs
+
+ val controller = createController()
+ controller.handleTabCounterItemInteraction(item)
+ assertEquals(BrowsingMode.Normal, browsingModeManager.mode)
+ verify { navController.navigate(BrowserFragmentDirections.actionGlobalHome(focusOnAddressBar = true)) }
+ }
+
+ @Test
+ fun handleToolbarNewPrivateTabPress() {
+ val browsingModeManager = SimpleBrowsingModeManager(BrowsingMode.Normal)
+ val item = TabCounterMenu.Item.NewPrivateTab
+
+ every { activity.browsingModeManager } returns browsingModeManager
+ every { navController.navigate(BrowserFragmentDirections.actionGlobalHome(focusOnAddressBar = true)) } just Runs
+
+ val controller = createController()
+ controller.handleTabCounterItemInteraction(item)
+ assertEquals(BrowsingMode.Private, browsingModeManager.mode)
+ verify { navController.navigate(BrowserFragmentDirections.actionGlobalHome(focusOnAddressBar = true)) }
+ }
+
+ @Test
+ fun `handleScroll for dynamic toolbars`() {
+ val controller = createController()
+ every { activity.settings().isDynamicToolbarEnabled } returns true
+
+ controller.handleScroll(10)
+ verify { engineView.setVerticalClipping(10) }
+ }
+
+ @Test
+ fun `handleScroll for static toolbars`() {
+ val controller = createController()
+ every { activity.settings().isDynamicToolbarEnabled } returns false
+
+ controller.handleScroll(10)
+ verify(exactly = 0) { engineView.setVerticalClipping(10) }
+ }
+
+ @Test
+ fun handleHomeButtonClick() {
+ assertNull(Events.browserToolbarHomeTapped.testGetValue())
+
+ val controller = createController()
+ controller.handleHomeButtonClick()
+
+ verify { navController.navigate(BrowserFragmentDirections.actionGlobalHome()) }
+ assertNotNull(Events.browserToolbarHomeTapped.testGetValue())
+ }
+
+ @Test
+ fun handleEraseButtonClicked() {
+ assertNull(Events.browserToolbarEraseTapped.testGetValue())
+ val controller = createController()
+ controller.handleEraseButtonClick()
+
+ verify {
+ homeViewModel.sessionToDelete = HomeFragment.ALL_PRIVATE_TABS
+ navController.navigate(BrowserFragmentDirections.actionGlobalHome())
+ }
+ assertNotNull(Events.browserToolbarEraseTapped.testGetValue())
+ }
+
+ @Test
+ fun handleShoppingCfrActionClick() {
+ val controller = createController()
+
+ controller.handleShoppingCfrActionClick()
+
+ verify {
+ navController.navigate(BrowserFragmentDirections.actionBrowserFragmentToReviewQualityCheckDialogFragment())
+ }
+ }
+
+ @Test
+ fun handleShoppingCfrDisplayedOnce() {
+ val controller = createController()
+ val mockSettings = mockk<Settings> {
+ every { reviewQualityCheckCfrDisplayTimeInMillis } returns System.currentTimeMillis()
+ every { reviewQualityCheckCfrDisplayTimeInMillis = any() } just Runs
+ every { reviewQualityCheckCFRClosedCounter } returns 1
+ every { reviewQualityCheckCFRClosedCounter = 2 } just Runs
+ every { shouldShowReviewQualityCheckCFR } returns true
+ }
+ every { activity.settings() } returns mockSettings
+
+ controller.handleShoppingCfrDisplayed()
+
+ verify(exactly = 0) { mockSettings.shouldShowReviewQualityCheckCFR = false }
+ verify { mockSettings.reviewQualityCheckCfrDisplayTimeInMillis = any() }
+ }
+
+ @Test
+ fun handleShoppingCfrDisplayedTwice() {
+ val controller = createController()
+ val mockSettings = mockk<Settings> {
+ every { reviewQualityCheckCfrDisplayTimeInMillis } returns System.currentTimeMillis()
+ every { reviewQualityCheckCfrDisplayTimeInMillis = any() } just Runs
+ every { reviewQualityCheckCFRClosedCounter } returns 2
+ every { reviewQualityCheckCFRClosedCounter = 3 } just Runs
+ every { shouldShowReviewQualityCheckCFR } returns true
+ }
+ every { activity.settings() } returns mockSettings
+
+ controller.handleShoppingCfrDisplayed()
+
+ verify(exactly = 0) { mockSettings.shouldShowReviewQualityCheckCFR = false }
+ verify { mockSettings.reviewQualityCheckCfrDisplayTimeInMillis = any() }
+ }
+
+ @Test
+ fun handleShoppingCfrDisplayedThreeTimes() {
+ val controller = createController()
+ val mockSettings = mockk<Settings> {
+ every { reviewQualityCheckCfrDisplayTimeInMillis } returns System.currentTimeMillis()
+ every { reviewQualityCheckCFRClosedCounter } returns 3
+ every { reviewQualityCheckCFRClosedCounter = 4 } just Runs
+ every { shouldShowReviewQualityCheckCFR } returns true
+ every { shouldShowReviewQualityCheckCFR = any() } just Runs
+ }
+ every { activity.settings() } returns mockSettings
+
+ controller.handleShoppingCfrDisplayed()
+
+ verify { mockSettings.shouldShowReviewQualityCheckCFR = false }
+ verify(exactly = 0) { mockSettings.reviewQualityCheckCfrDisplayTimeInMillis = any() }
+ }
+
+ @Test
+ fun handleTranslationsButtonClick() {
+ val controller = createController()
+ controller.handleTranslationsButtonClick()
+
+ verify {
+ navController.navigate(
+ BrowserFragmentDirections.actionBrowserFragmentToTranslationsDialogFragment(
+ sessionId = "1",
+ ),
+ )
+ }
+
+ val telemetry = Translations.action.testGetValue()?.firstOrNull()
+ assertEquals("main_flow_toolbar", telemetry?.extra?.get("item"))
+ }
+
+ private fun createController(
+ activity: HomeActivity = this.activity,
+ customTabSessionId: String? = null,
+ ) = DefaultBrowserToolbarController(
+ store = store,
+ tabsUseCases = tabsUseCases,
+ activity = activity,
+ navController = navController,
+ engineView = engineView,
+ homeViewModel = homeViewModel,
+ customTabSessionId = customTabSessionId,
+ readerModeController = readerModeController,
+ browserAnimator = browserAnimator,
+ onTabCounterClicked = {
+ tabCounterClicked = true
+ },
+ onCloseTab = {},
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/DefaultBrowserToolbarInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/DefaultBrowserToolbarInteractorTest.kt
new file mode 100644
index 0000000000..b6d9f80923
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/DefaultBrowserToolbarInteractorTest.kt
@@ -0,0 +1,104 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.toolbar
+
+import io.mockk.MockKAnnotations
+import io.mockk.impl.annotations.RelaxedMockK
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.ui.tabcounter.TabCounterMenu
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.components.toolbar.interactor.DefaultBrowserToolbarInteractor
+
+class DefaultBrowserToolbarInteractorTest {
+
+ @RelaxedMockK lateinit var browserToolbarController: BrowserToolbarController
+
+ @RelaxedMockK lateinit var browserToolbarMenuController: BrowserToolbarMenuController
+ lateinit var interactor: DefaultBrowserToolbarInteractor
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ interactor = DefaultBrowserToolbarInteractor(
+ browserToolbarController,
+ browserToolbarMenuController,
+ )
+ }
+
+ @Test
+ fun onTabCounterClicked() {
+ interactor.onTabCounterClicked()
+ verify { browserToolbarController.handleTabCounterClick() }
+ }
+
+ @Test
+ fun onTabCounterMenuItemTapped() {
+ val item: TabCounterMenu.Item = mockk()
+
+ interactor.onTabCounterMenuItemTapped(item)
+ verify { browserToolbarController.handleTabCounterItemInteraction(item) }
+ }
+
+ @Test
+ fun onBrowserToolbarPaste() {
+ val pastedText = "Mozilla"
+ interactor.onBrowserToolbarPaste(pastedText)
+ verify { browserToolbarController.handleToolbarPaste(pastedText) }
+ }
+
+ @Test
+ fun onBrowserToolbarPasteAndGo() {
+ val pastedText = "Mozilla"
+
+ interactor.onBrowserToolbarPasteAndGo(pastedText)
+ verify { browserToolbarController.handleToolbarPasteAndGo(pastedText) }
+ }
+
+ @Test
+ fun onBrowserToolbarClicked() {
+ interactor.onBrowserToolbarClicked()
+
+ verify { browserToolbarController.handleToolbarClick() }
+ }
+
+ @Test
+ fun onBrowserToolbarMenuItemTapped() {
+ val item: ToolbarMenu.Item = mockk()
+
+ interactor.onBrowserToolbarMenuItemTapped(item)
+
+ verify { browserToolbarMenuController.handleToolbarItemInteraction(item) }
+ }
+
+ @Test
+ fun onHomeButtonClicked() {
+ interactor.onHomeButtonClicked()
+
+ verify { browserToolbarController.handleHomeButtonClick() }
+ }
+
+ @Test
+ fun onEraseButtonClicked() {
+ interactor.onEraseButtonClicked()
+
+ verify { browserToolbarController.handleEraseButtonClick() }
+ }
+
+ @Test
+ fun onShoppingCfrActionClicked() {
+ interactor.onShoppingCfrActionClicked()
+
+ verify { browserToolbarController.handleShoppingCfrActionClick() }
+ }
+
+ @Test
+ fun onTranslationsButtonClicked() {
+ interactor.onTranslationsButtonClicked()
+
+ verify { browserToolbarController.handleTranslationsButtonClick() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/DefaultBrowserToolbarMenuControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/DefaultBrowserToolbarMenuControllerTest.kt
new file mode 100644
index 0000000000..781cb8b3cf
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/DefaultBrowserToolbarMenuControllerTest.kt
@@ -0,0 +1,878 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.toolbar
+
+import android.content.Intent
+import android.view.ViewGroup
+import androidx.navigation.NavController
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.impl.annotations.RelaxedMockK
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.mockkStatic
+import io.mockk.slot
+import io.mockk.unmockkObject
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import mozilla.appservices.places.BookmarkRoot
+import mozilla.components.browser.state.action.CustomTabListAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.ReaderState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.createCustomTab
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.concept.engine.prompt.ShareData
+import mozilla.components.feature.search.SearchUseCases
+import mozilla.components.feature.session.SessionFeature
+import mozilla.components.feature.session.SessionUseCases
+import mozilla.components.feature.tab.collections.TabCollection
+import mozilla.components.feature.tabs.CustomTabsUseCases
+import mozilla.components.feature.top.sites.DefaultTopSitesStorage
+import mozilla.components.feature.top.sites.PinnedSiteStorage
+import mozilla.components.feature.top.sites.TopSite
+import mozilla.components.feature.top.sites.TopSitesUseCases
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.Collections
+import org.mozilla.fenix.GleanMetrics.Events
+import org.mozilla.fenix.GleanMetrics.ReaderMode
+import org.mozilla.fenix.GleanMetrics.Translations
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.NavGraphDirections
+import org.mozilla.fenix.R
+import org.mozilla.fenix.browser.BrowserAnimator
+import org.mozilla.fenix.browser.BrowserFragmentDirections
+import org.mozilla.fenix.browser.readermode.ReaderModeController
+import org.mozilla.fenix.collections.SaveCollectionStep
+import org.mozilla.fenix.components.FenixSnackbar
+import org.mozilla.fenix.components.TabCollectionStorage
+import org.mozilla.fenix.components.accounts.AccountState
+import org.mozilla.fenix.components.accounts.FenixFxAEntryPoint
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.directionsEq
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.deletebrowsingdata.deleteAndQuit
+import org.mozilla.fenix.utils.Settings
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(FenixRobolectricTestRunner::class)
+class DefaultBrowserToolbarMenuControllerTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @MockK private lateinit var snackbarParent: ViewGroup
+
+ @RelaxedMockK private lateinit var activity: HomeActivity
+
+ @RelaxedMockK private lateinit var navController: NavController
+
+ @RelaxedMockK private lateinit var openInFenixIntent: Intent
+
+ @RelaxedMockK private lateinit var settings: Settings
+
+ @RelaxedMockK private lateinit var searchUseCases: SearchUseCases
+
+ @RelaxedMockK private lateinit var sessionUseCases: SessionUseCases
+
+ @RelaxedMockK private lateinit var customTabUseCases: CustomTabsUseCases
+
+ @RelaxedMockK private lateinit var browserAnimator: BrowserAnimator
+
+ @RelaxedMockK private lateinit var snackbar: FenixSnackbar
+
+ @RelaxedMockK private lateinit var tabCollectionStorage: TabCollectionStorage
+
+ @RelaxedMockK private lateinit var topSitesUseCase: TopSitesUseCases
+
+ @RelaxedMockK private lateinit var readerModeController: ReaderModeController
+
+ @MockK private lateinit var sessionFeatureWrapper: ViewBoundFeatureWrapper<SessionFeature>
+
+ @RelaxedMockK private lateinit var sessionFeature: SessionFeature
+
+ @RelaxedMockK private lateinit var topSitesStorage: DefaultTopSitesStorage
+
+ @RelaxedMockK private lateinit var pinnedSiteStorage: PinnedSiteStorage
+
+ private lateinit var browserStore: BrowserStore
+ private lateinit var selectedTab: TabSessionState
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+
+ mockkStatic(
+ "org.mozilla.fenix.settings.deletebrowsingdata.DeleteAndQuitKt",
+ )
+ every { deleteAndQuit(any(), any(), any()) } just Runs
+
+ mockkObject(FenixSnackbar.Companion)
+ every { FenixSnackbar.make(any(), any(), any(), any()) } returns snackbar
+
+ every { activity.components.useCases.sessionUseCases } returns sessionUseCases
+ every { activity.components.useCases.customTabsUseCases } returns customTabUseCases
+ every { activity.components.useCases.searchUseCases } returns searchUseCases
+ every { activity.components.useCases.topSitesUseCase } returns topSitesUseCase
+ every { sessionFeatureWrapper.get() } returns sessionFeature
+ every { navController.currentDestination } returns mockk {
+ every { id } returns R.id.browserFragment
+ }
+ every { settings.topSitesMaxLimit } returns 16
+
+ val onComplete = slot<(Boolean) -> Unit>()
+ every { browserAnimator.captureEngineViewAndDrawStatically(any(), capture(onComplete)) } answers { onComplete.captured.invoke(true) }
+
+ selectedTab = createTab("https://www.mozilla.org", id = "1")
+ browserStore = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(selectedTab),
+ selectedTabId = selectedTab.id,
+ ),
+ )
+ }
+
+ @After
+ fun tearDown() {
+ unmockkStatic("org.mozilla.fenix.settings.deletebrowsingdata.DeleteAndQuitKt")
+ unmockkObject(FenixSnackbar.Companion)
+ }
+
+ @Test
+ fun handleToolbarBookmarkPressWithReaderModeInactive() = runTest {
+ val item = ToolbarMenu.Item.Bookmark
+
+ val expectedTitle = "Mozilla"
+ val expectedUrl = "https://mozilla.org"
+ val regularTab = createTab(
+ url = expectedUrl,
+ readerState = ReaderState(active = false, activeUrl = "https://1234.org"),
+ title = expectedTitle,
+ )
+ val store =
+ BrowserStore(BrowserState(tabs = listOf(regularTab), selectedTabId = regularTab.id))
+
+ var bookmarkTappedInvoked = false
+ val controller = createController(
+ scope = this,
+ store = store,
+ bookmarkTapped = { url, title ->
+ assertEquals(expectedTitle, title)
+ assertEquals(expectedUrl, url)
+ bookmarkTappedInvoked = true
+ },
+ )
+ assertNull(Events.browserMenuAction.testGetValue())
+
+ controller.handleToolbarItemInteraction(item)
+
+ assertNotNull(Events.browserMenuAction.testGetValue())
+ val snapshot = Events.browserMenuAction.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("bookmark", snapshot.single().extra?.getValue("item"))
+
+ assertTrue(bookmarkTappedInvoked)
+ }
+
+ @Test
+ fun `IF reader mode is active WHEN bookmark menu item is pressed THEN menu item is handled`() = runTest {
+ val item = ToolbarMenu.Item.Bookmark
+ val expectedTitle = "Mozilla"
+ val readerUrl = "moz-extension://1234"
+ val readerTab = createTab(
+ url = readerUrl,
+ readerState = ReaderState(active = true, activeUrl = "https://mozilla.org"),
+ title = expectedTitle,
+ )
+ browserStore =
+ BrowserStore(BrowserState(tabs = listOf(readerTab), selectedTabId = readerTab.id))
+
+ var bookmarkTappedInvoked = false
+ val controller = createController(
+ scope = this,
+ store = browserStore,
+ bookmarkTapped = { url, title ->
+ assertEquals(expectedTitle, title)
+ assertEquals(readerTab.readerState.activeUrl, url)
+ bookmarkTappedInvoked = true
+ },
+ )
+ assertNull(Events.browserMenuAction.testGetValue())
+
+ controller.handleToolbarItemInteraction(item)
+
+ assertNotNull(Events.browserMenuAction.testGetValue())
+ val snapshot = Events.browserMenuAction.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("bookmark", snapshot.single().extra?.getValue("item"))
+
+ assertTrue(bookmarkTappedInvoked)
+ }
+
+ @Test
+ fun `WHEN open in Fenix menu item is pressed THEN menu item is handled correctly`() = runTest {
+ val customTab = createCustomTab("https://mozilla.org")
+ browserStore.dispatch(CustomTabListAction.AddCustomTabAction(customTab)).joinBlocking()
+ val controller = createController(
+ scope = this,
+ store = browserStore,
+ customTabSessionId = customTab.id,
+ )
+
+ val item = ToolbarMenu.Item.OpenInFenix
+
+ every { activity.startActivity(any()) } just Runs
+ controller.handleToolbarItemInteraction(item)
+
+ verify { sessionFeature.release() }
+ verify { customTabUseCases.migrate(customTab.id, true) }
+ verify { activity.startActivity(openInFenixIntent) }
+ verify { activity.finishAndRemoveTask() }
+ }
+
+ @Test
+ fun `WHEN reader mode menu item is pressed THEN handle appearance change`() = runTest {
+ val item = ToolbarMenu.Item.CustomizeReaderView
+ assertNull(ReaderMode.appearance.testGetValue())
+
+ val controller = createController(scope = this, store = browserStore)
+
+ controller.handleToolbarItemInteraction(item)
+
+ verify { readerModeController.showControls() }
+ assertNotNull(ReaderMode.appearance.testGetValue())
+ assertNull(ReaderMode.appearance.testGetValue()!!.single().extra)
+ }
+
+ @Test
+ fun `WHEN quit menu item is pressed THEN menu item is handled correctly`() = runTest {
+ val item = ToolbarMenu.Item.Quit
+ val testScope = this
+
+ val controller = createController(scope = this, store = browserStore)
+
+ controller.handleToolbarItemInteraction(item)
+
+ verify { deleteAndQuit(activity, testScope, null) }
+ }
+
+ @Test
+ fun `WHEN backwards nav menu item is pressed THEN the session navigates back with active session`() = runTest {
+ val item = ToolbarMenu.Item.Back(false)
+
+ val controller = createController(scope = this, store = browserStore)
+ assertNull(Events.browserMenuAction.testGetValue())
+
+ controller.handleToolbarItemInteraction(item)
+
+ assertNotNull(Events.browserMenuAction.testGetValue())
+ val snapshot = Events.browserMenuAction.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("back", snapshot.single().extra?.getValue("item"))
+
+ verify { sessionUseCases.goBack(browserStore.state.selectedTabId!!) }
+ }
+
+ @Test
+ fun `WHEN backwards nav menu item is long pressed THEN the session navigates back with no active session`() = runTest {
+ val item = ToolbarMenu.Item.Back(true)
+
+ val controller = createController(scope = this, store = browserStore)
+ assertNull(Events.browserMenuAction.testGetValue())
+
+ controller.handleToolbarItemInteraction(item)
+
+ assertNotNull(Events.browserMenuAction.testGetValue())
+ val snapshot = Events.browserMenuAction.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("back_long_press", snapshot.single().extra?.getValue("item"))
+ val directions = BrowserFragmentDirections.actionGlobalTabHistoryDialogFragment(null)
+
+ verify { navController.navigate(directions) }
+ }
+
+ @Test
+ fun `WHEN forward nav menu item is pressed THEN the session navigates forward to active session`() = runTest {
+ val item = ToolbarMenu.Item.Forward(false)
+
+ val controller = createController(scope = this, store = browserStore)
+ assertNull(Events.browserMenuAction.testGetValue())
+
+ controller.handleToolbarItemInteraction(item)
+
+ assertNotNull(Events.browserMenuAction.testGetValue())
+ val snapshot = Events.browserMenuAction.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("forward", snapshot.single().extra?.getValue("item"))
+
+ verify { sessionUseCases.goForward(selectedTab.id) }
+ }
+
+ @Test
+ fun `WHEN forward nav menu item is long pressed THEN the browser navigates forward with no active session`() = runTest {
+ val item = ToolbarMenu.Item.Forward(true)
+
+ val controller = createController(scope = this, store = browserStore)
+ assertNull(Events.browserMenuAction.testGetValue())
+
+ controller.handleToolbarItemInteraction(item)
+
+ assertNotNull(Events.browserMenuAction.testGetValue())
+ val snapshot = Events.browserMenuAction.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("forward_long_press", snapshot.single().extra?.getValue("item"))
+
+ val directions = BrowserFragmentDirections.actionGlobalTabHistoryDialogFragment(null)
+
+ verify { navController.navigate(directions) }
+ }
+
+ @Test
+ fun `WHEN reload nav menu item is pressed THEN the session reloads from cache`() = runTest {
+ val item = ToolbarMenu.Item.Reload(false)
+
+ val controller = createController(scope = this, store = browserStore)
+ assertNull(Events.browserMenuAction.testGetValue())
+
+ controller.handleToolbarItemInteraction(item)
+
+ assertNotNull(Events.browserMenuAction.testGetValue())
+ val snapshot = Events.browserMenuAction.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("reload", snapshot.single().extra?.getValue("item"))
+
+ verify { sessionUseCases.reload(selectedTab.id) }
+ }
+
+ @Test
+ fun `WHEN reload nav menu item is long pressed THEN the session reloads with no cache`() = runTest {
+ val item = ToolbarMenu.Item.Reload(true)
+
+ val controller = createController(scope = this, store = browserStore)
+ assertNull(Events.browserMenuAction.testGetValue())
+
+ controller.handleToolbarItemInteraction(item)
+
+ assertNotNull(Events.browserMenuAction.testGetValue())
+ val snapshot = Events.browserMenuAction.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("reload", snapshot.single().extra?.getValue("item"))
+
+ verify {
+ sessionUseCases.reload(
+ selectedTab.id,
+ EngineSession.LoadUrlFlags.select(EngineSession.LoadUrlFlags.BYPASS_CACHE),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN stop nav menu item is pressed THEN the session stops loading`() = runTest {
+ val item = ToolbarMenu.Item.Stop
+
+ val controller = createController(scope = this, store = browserStore)
+ assertNull(Events.browserMenuAction.testGetValue())
+
+ controller.handleToolbarItemInteraction(item)
+
+ assertNotNull(Events.browserMenuAction.testGetValue())
+ val snapshot = Events.browserMenuAction.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("stop", snapshot.single().extra?.getValue("item"))
+
+ verify { sessionUseCases.stopLoading(selectedTab.id) }
+ }
+
+ @Test
+ fun `WHEN settings menu item is pressed THEN menu item is handled`() = runTest {
+ val item = ToolbarMenu.Item.Settings
+
+ val controller = createController(scope = this, store = browserStore)
+ assertNull(Events.browserMenuAction.testGetValue())
+
+ controller.handleToolbarItemInteraction(item)
+
+ assertNotNull(Events.browserMenuAction.testGetValue())
+ val snapshot = Events.browserMenuAction.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("settings", snapshot.single().extra?.getValue("item"))
+ val directions = BrowserFragmentDirections.actionBrowserFragmentToSettingsFragment()
+
+ verify { navController.navigate(directions, null) }
+ }
+
+ @Test
+ fun `WHEN bookmark menu item is pressed THEN navigate to bookmarks page`() = runTest {
+ val item = ToolbarMenu.Item.Bookmarks
+
+ val controller = createController(scope = this, store = browserStore)
+ assertNull(Events.browserMenuAction.testGetValue())
+
+ controller.handleToolbarItemInteraction(item)
+
+ assertNotNull(Events.browserMenuAction.testGetValue())
+ val snapshot = Events.browserMenuAction.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("bookmarks", snapshot.single().extra?.getValue("item"))
+ val directions = BrowserFragmentDirections.actionGlobalBookmarkFragment(BookmarkRoot.Mobile.id)
+
+ verify { navController.navigate(directions, null) }
+ }
+
+ @Test
+ fun `WHEN history menu item is pressed THEN navigate to history page`() = runTest {
+ val item = ToolbarMenu.Item.History
+
+ val controller = createController(scope = this, store = browserStore)
+ assertNull(Events.browserMenuAction.testGetValue())
+
+ controller.handleToolbarItemInteraction(item)
+
+ assertNotNull(Events.browserMenuAction.testGetValue())
+ val snapshot = Events.browserMenuAction.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("history", snapshot.single().extra?.getValue("item"))
+ val directions = BrowserFragmentDirections.actionGlobalHistoryFragment()
+
+ verify { navController.navigate(directions, null) }
+ }
+
+ @Test
+ fun `WHEN request desktop menu item is toggled On THEN desktop site is requested for the session`() = runTest {
+ val requestDesktopSiteUseCase: SessionUseCases.RequestDesktopSiteUseCase =
+ mockk(relaxed = true)
+ val item = ToolbarMenu.Item.RequestDesktop(true)
+
+ every { sessionUseCases.requestDesktopSite } returns requestDesktopSiteUseCase
+
+ val controller = createController(scope = this, store = browserStore)
+ assertNull(Events.browserMenuAction.testGetValue())
+
+ controller.handleToolbarItemInteraction(item)
+
+ assertNotNull(Events.browserMenuAction.testGetValue())
+ val snapshot = Events.browserMenuAction.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("desktop_view_on", snapshot.single().extra?.getValue("item"))
+
+ verify {
+ requestDesktopSiteUseCase.invoke(
+ true,
+ selectedTab.id,
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN request desktop menu item is toggled Off THEN mobile site is requested for the session`() = runTest {
+ val requestDesktopSiteUseCase: SessionUseCases.RequestDesktopSiteUseCase =
+ mockk(relaxed = true)
+ val item = ToolbarMenu.Item.RequestDesktop(false)
+
+ every { sessionUseCases.requestDesktopSite } returns requestDesktopSiteUseCase
+
+ val controller = createController(scope = this, store = browserStore)
+ assertNull(Events.browserMenuAction.testGetValue())
+
+ controller.handleToolbarItemInteraction(item)
+
+ assertNotNull(Events.browserMenuAction.testGetValue())
+ val snapshot = Events.browserMenuAction.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("desktop_view_off", snapshot.single().extra?.getValue("item"))
+
+ verify {
+ requestDesktopSiteUseCase.invoke(
+ false,
+ selectedTab.id,
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN add to shortcuts menu item is pressed THEN add site AND show snackbar`() = runTestOnMain {
+ val item = ToolbarMenu.Item.AddToTopSites
+ val addPinnedSiteUseCase: TopSitesUseCases.AddPinnedSiteUseCase = mockk(relaxed = true)
+
+ every { topSitesUseCase.addPinnedSites } returns addPinnedSiteUseCase
+ every {
+ snackbarParent.context.getString(R.string.snackbar_added_to_shortcuts)
+ } returns "Added to shortcuts!"
+
+ val controller = createController(scope = this, store = browserStore)
+ assertNull(Events.browserMenuAction.testGetValue())
+
+ controller.handleToolbarItemInteraction(item)
+
+ assertNotNull(Events.browserMenuAction.testGetValue())
+ val snapshot = Events.browserMenuAction.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("add_to_top_sites", snapshot.single().extra?.getValue("item"))
+
+ verify { addPinnedSiteUseCase.invoke(selectedTab.content.title, selectedTab.content.url) }
+ verify { snackbar.setText("Added to shortcuts!") }
+ }
+
+ @Test
+ fun `GIVEN a shortcut page is open WHEN remove from shortcuts is pressed THEN show snackbar`() = runTestOnMain {
+ val snackbarMessage = "Site removed"
+ val item = ToolbarMenu.Item.RemoveFromTopSites
+ val removePinnedSiteUseCase: TopSitesUseCases.RemoveTopSiteUseCase =
+ mockk(relaxed = true)
+ val topSite: TopSite = mockk()
+ every { topSite.url } returns selectedTab.content.url
+ coEvery { pinnedSiteStorage.getPinnedSites() } returns listOf(topSite)
+ every { topSitesUseCase.removeTopSites } returns removePinnedSiteUseCase
+ every {
+ snackbarParent.context.getString(R.string.snackbar_top_site_removed)
+ } returns snackbarMessage
+
+ val controller = createController(scope = this, store = browserStore)
+ assertNull(Events.browserMenuAction.testGetValue())
+
+ controller.handleToolbarItemInteraction(item)
+
+ assertNotNull(Events.browserMenuAction.testGetValue())
+ val snapshot = Events.browserMenuAction.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("remove_from_top_sites", snapshot.single().extra?.getValue("item"))
+
+ verify { snackbar.setText(snackbarMessage) }
+ verify { removePinnedSiteUseCase.invoke(topSite) }
+ }
+
+ @Test
+ fun `WHEN addon extensions menu item is pressed THEN navigate to addons manager`() = runTest {
+ val item = ToolbarMenu.Item.AddonsManager
+
+ val controller = createController(scope = this, store = browserStore)
+ assertNull(Events.browserMenuAction.testGetValue())
+
+ controller.handleToolbarItemInteraction(item)
+
+ assertNotNull(Events.browserMenuAction.testGetValue())
+ val snapshot = Events.browserMenuAction.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("addons_manager", snapshot.single().extra?.getValue("item"))
+ }
+
+ @Test
+ fun `WHEN Add To Home Screen menu item is pressed THEN add site`() = runTest {
+ val item = ToolbarMenu.Item.AddToHomeScreen
+
+ val controller = createController(scope = this, store = browserStore)
+ assertNull(Events.browserMenuAction.testGetValue())
+
+ controller.handleToolbarItemInteraction(item)
+
+ assertNotNull(Events.browserMenuAction.testGetValue())
+ val snapshot = Events.browserMenuAction.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("add_to_homescreen", snapshot.single().extra?.getValue("item"))
+ }
+
+ @Test
+ fun `IF reader mode is inactive WHEN share menu item is pressed THEN navigate to share screen`() = runTest {
+ val item = ToolbarMenu.Item.Share
+ val title = "Mozilla"
+ val url = "https://mozilla.org"
+ val regularTab = createTab(
+ url = url,
+ readerState = ReaderState(active = false, activeUrl = "https://1234.org"),
+ title = title,
+ )
+ browserStore = BrowserStore(BrowserState(tabs = listOf(regularTab), selectedTabId = regularTab.id))
+ val controller = createController(scope = this, store = browserStore)
+ assertNull(Events.browserMenuAction.testGetValue())
+
+ controller.handleToolbarItemInteraction(item)
+
+ assertNotNull(Events.browserMenuAction.testGetValue())
+ val snapshot = Events.browserMenuAction.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("share", snapshot.single().extra?.getValue("item"))
+
+ verify {
+ navController.navigate(
+ directionsEq(
+ NavGraphDirections.actionGlobalShareFragment(
+ sessionId = browserStore.state.selectedTabId,
+ data = arrayOf(ShareData(url = "https://mozilla.org", title = "Mozilla")),
+ showPage = true,
+ ),
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `IF reader mode is active WHEN share menu item is pressed THEN navigate to share screen`() = runTest {
+ val item = ToolbarMenu.Item.Share
+ val title = "Mozilla"
+ val readerUrl = "moz-extension://1234"
+ val readerTab = createTab(
+ url = readerUrl,
+ readerState = ReaderState(active = true, activeUrl = "https://mozilla.org"),
+ title = title,
+ )
+ browserStore = BrowserStore(BrowserState(tabs = listOf(readerTab), selectedTabId = readerTab.id))
+ val controller = createController(scope = this, store = browserStore)
+ assertNull(Events.browserMenuAction.testGetValue())
+
+ controller.handleToolbarItemInteraction(item)
+
+ assertNotNull(Events.browserMenuAction.testGetValue())
+ val snapshot = Events.browserMenuAction.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("share", snapshot.single().extra?.getValue("item"))
+
+ verify {
+ navController.navigate(
+ directionsEq(
+ NavGraphDirections.actionGlobalShareFragment(
+ sessionId = browserStore.state.selectedTabId,
+ data = arrayOf(ShareData(url = "https://mozilla.org", title = "Mozilla")),
+ showPage = true,
+ ),
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN Find In Page menu item is pressed THEN launch finder`() = runTest {
+ val item = ToolbarMenu.Item.FindInPage
+
+ var launcherInvoked = false
+ val controller = createController(
+ scope = this,
+ store = browserStore,
+ findInPageLauncher = {
+ launcherInvoked = true
+ },
+ )
+ controller.handleToolbarItemInteraction(item)
+
+ assertTrue(launcherInvoked)
+ }
+
+ @Test
+ fun `IF one or more collection exists WHEN Save To Collection menu item is pressed THEN navigate to save collection page`() = runTest {
+ val item = ToolbarMenu.Item.SaveToCollection
+ val cachedTabCollections: List<TabCollection> = mockk(relaxed = true)
+ every { tabCollectionStorage.cachedTabCollections } returns cachedTabCollections
+
+ val controller = createController(scope = this, store = browserStore)
+ assertNull(Events.browserMenuAction.testGetValue())
+
+ controller.handleToolbarItemInteraction(item)
+
+ assertNotNull(Events.browserMenuAction.testGetValue())
+ val snapshot = Events.browserMenuAction.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("save_to_collection", snapshot.single().extra?.getValue("item"))
+
+ assertNotNull(Collections.saveButton.testGetValue())
+ val recordedEvents = Collections.saveButton.testGetValue()!!
+ assertEquals(1, recordedEvents.size)
+ val eventExtra = recordedEvents.single().extra
+ assertNotNull(eventExtra)
+ assertTrue(eventExtra!!.containsKey("from_screen"))
+ assertEquals(
+ DefaultBrowserToolbarMenuController.TELEMETRY_BROWSER_IDENTIFIER,
+ eventExtra["from_screen"],
+ )
+
+ val directions = BrowserFragmentDirections.actionGlobalCollectionCreationFragment(
+ saveCollectionStep = SaveCollectionStep.SelectCollection,
+ tabIds = arrayOf(selectedTab.id),
+ selectedTabIds = arrayOf(selectedTab.id),
+ )
+ verify { navController.navigate(directionsEq(directions), null) }
+ }
+
+ @Test
+ fun `IF no collection exists WHEN Save To Collection menu item is pressed THEN navigate to create collection page`() = runTest {
+ val item = ToolbarMenu.Item.SaveToCollection
+ val cachedTabCollectionsEmpty: List<TabCollection> = emptyList()
+ every { tabCollectionStorage.cachedTabCollections } returns cachedTabCollectionsEmpty
+
+ val controller = createController(scope = this, store = browserStore)
+ assertNull(Events.browserMenuAction.testGetValue())
+
+ controller.handleToolbarItemInteraction(item)
+
+ assertNotNull(Events.browserMenuAction.testGetValue())
+ val snapshot = Events.browserMenuAction.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("save_to_collection", snapshot.single().extra?.getValue("item"))
+
+ assertNotNull(Collections.saveButton.testGetValue())
+ val recordedEvents = Collections.saveButton.testGetValue()!!
+ assertEquals(1, recordedEvents.size)
+ val eventExtra = recordedEvents.single().extra
+ assertNotNull(eventExtra)
+ assertTrue(eventExtra!!.containsKey("from_screen"))
+ assertEquals(
+ DefaultBrowserToolbarMenuController.TELEMETRY_BROWSER_IDENTIFIER,
+ eventExtra["from_screen"],
+ )
+ val directions = BrowserFragmentDirections.actionGlobalCollectionCreationFragment(
+ saveCollectionStep = SaveCollectionStep.NameCollection,
+ tabIds = arrayOf(selectedTab.id),
+ selectedTabIds = arrayOf(selectedTab.id),
+ )
+ verify { navController.navigate(directionsEq(directions), null) }
+ }
+
+ @Test
+ fun `WHEN print menu item is pressed THEN request print`() = runTest {
+ val item = ToolbarMenu.Item.PrintContent
+
+ val controller = createController(scope = this, store = browserStore)
+ assertNull(Events.browserMenuAction.testGetValue())
+
+ controller.handleToolbarItemInteraction(item)
+
+ assertNotNull(Events.browserMenuAction.testGetValue())
+ val snapshot = Events.browserMenuAction.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("print_content", snapshot.single().extra?.getValue("item"))
+ }
+
+ @Test
+ fun `WHEN New Tab menu item is pressed THEN navigate to a new tab home`() = runTest {
+ val item = ToolbarMenu.Item.NewTab
+
+ val controller = createController(scope = this, store = browserStore)
+
+ controller.handleToolbarItemInteraction(item)
+
+ verify {
+ navController.navigate(
+ directionsEq(
+ NavGraphDirections.actionGlobalHome(
+ focusOnAddressBar = true,
+ ),
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN account exists and the user is signed in WHEN sign in to sync menu item is pressed THEN navigate to account settings`() = runTest {
+ val item = ToolbarMenu.Item.SyncAccount(AccountState.AUTHENTICATED)
+ val accountSettingsDirections = BrowserFragmentDirections.actionGlobalAccountSettingsFragment()
+ val controller = createController(scope = this, store = browserStore)
+
+ controller.handleToolbarItemInteraction(item)
+
+ verify { navController.navigate(accountSettingsDirections, null) }
+ }
+
+ @Test
+ fun `GIVEN account exists and the user is not signed in WHEN sign in to sync menu item is pressed THEN navigate to account problem fragment`() = runTest {
+ val item = ToolbarMenu.Item.SyncAccount(AccountState.NEEDS_REAUTHENTICATION)
+ val accountProblemDirections = BrowserFragmentDirections.actionGlobalAccountProblemFragment(
+ entrypoint = FenixFxAEntryPoint.BrowserToolbar,
+ )
+ val controller = createController(scope = this, store = browserStore)
+
+ controller.handleToolbarItemInteraction(item)
+
+ verify { navController.navigate(accountProblemDirections, null) }
+ }
+
+ @Test
+ fun `GIVEN account doesn't exist WHEN sign in to sync menu item is pressed THEN navigate to sign in`() = runTest {
+ val item = ToolbarMenu.Item.SyncAccount(AccountState.NO_ACCOUNT)
+ val turnOnSyncDirections = BrowserFragmentDirections.actionGlobalTurnOnSync(
+ entrypoint = FenixFxAEntryPoint.BrowserToolbar,
+ )
+ val controller = createController(scope = this, store = browserStore)
+
+ controller.handleToolbarItemInteraction(item)
+
+ verify { navController.navigate(turnOnSyncDirections, null) }
+ }
+
+ @Test
+ fun `WHEN the Translations menu item is pressed THEN navigate to translations flow AND post telemetry`() =
+ runTest {
+ val item = ToolbarMenu.Item.Translate
+
+ val controller = createController(scope = this, store = browserStore)
+
+ controller.handleToolbarItemInteraction(item)
+
+ verify {
+ navController.navigate(
+ directionsEq(
+ BrowserFragmentDirections.actionBrowserFragmentToTranslationsDialogFragment(
+ sessionId = selectedTab.id,
+ ),
+ ),
+ )
+ }
+
+ val telemetry = Translations.action.testGetValue()?.firstOrNull()
+ assertEquals("main_flow_browser", telemetry?.extra?.get("item"))
+ }
+
+ private fun createController(
+ scope: CoroutineScope,
+ store: BrowserStore,
+ activity: HomeActivity = this.activity,
+ customTabSessionId: String? = null,
+ findInPageLauncher: () -> Unit = { },
+ bookmarkTapped: (String, String) -> Unit = { _, _ -> },
+ ) = DefaultBrowserToolbarMenuController(
+ store = store,
+ activity = activity,
+ navController = navController,
+ settings = settings,
+ findInPageLauncher = findInPageLauncher,
+ browserAnimator = browserAnimator,
+ customTabSessionId = customTabSessionId,
+ openInFenixIntent = openInFenixIntent,
+ scope = scope,
+ snackbarParent = snackbarParent,
+ tabCollectionStorage = tabCollectionStorage,
+ bookmarkTapped = bookmarkTapped,
+ readerModeController = readerModeController,
+ sessionFeature = sessionFeatureWrapper,
+ topSitesStorage = topSitesStorage,
+ pinnedSiteStorage = pinnedSiteStorage,
+ browserStore = browserStore,
+ ).apply {
+ ioScope = scope
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/DefaultToolbarIntegrationTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/DefaultToolbarIntegrationTest.kt
new file mode 100644
index 0000000000..ec1624d15c
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/DefaultToolbarIntegrationTest.kt
@@ -0,0 +1,72 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.toolbar
+
+import android.content.Context
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class DefaultToolbarIntegrationTest {
+ private lateinit var feature: DefaultToolbarIntegration
+
+ @Before
+ fun setup() {
+ mockkStatic("org.mozilla.fenix.ext.ContextKt")
+ every { any<Context>().components } returns mockk {
+ every { core } returns mockk {
+ every { store } returns BrowserStore()
+ }
+ every { publicSuffixList } returns mockk()
+ every { settings } returns mockk(relaxed = true)
+ }
+
+ feature = DefaultToolbarIntegration(
+ context = testContext,
+ toolbar = mockk(relaxed = true),
+ scrollableToolbar = mockk(relaxed = true),
+ toolbarMenu = mockk(relaxed = true),
+ lifecycleOwner = mockk(),
+ sessionId = null,
+ isPrivate = false,
+ interactor = mockk(),
+ isNavBarEnabled = false,
+ )
+ }
+
+ @After
+ fun teardown() {
+ unmockkStatic("org.mozilla.fenix.ext.ContextKt")
+ }
+
+ @Test
+ fun `WHEN the feature starts THEN start the cfr presenter`() {
+ feature.cfrPresenter = mockk(relaxed = true)
+
+ feature.start()
+
+ verify { feature.cfrPresenter.start() }
+ }
+
+ @Test
+ fun `WHEN the feature stops THEN stop the cfr presenter`() {
+ feature.cfrPresenter = mockk(relaxed = true)
+
+ feature.stop()
+
+ verify { feature.cfrPresenter.stop() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/EngineViewClippingBehaviorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/EngineViewClippingBehaviorTest.kt
new file mode 100644
index 0000000000..688a3c1d9a
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/EngineViewClippingBehaviorTest.kt
@@ -0,0 +1,292 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.toolbar
+
+import android.view.View
+import android.widget.EditText
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.toolbar.BrowserToolbar
+import mozilla.components.concept.engine.EngineView
+import mozilla.components.support.test.fakes.engine.FakeEngineView
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+import org.mozilla.fenix.components.toolbar.navbar.EngineViewClippingBehavior
+import org.mozilla.fenix.components.toolbar.navbar.ToolbarContainerView
+
+@RunWith(AndroidJUnit4::class)
+class EngineViewClippingBehaviorTest {
+
+ // Bottom toolbar position tests
+ @Test
+ fun `GIVEN the toolbar is at the bottom WHEN toolbar is being shifted THEN EngineView adjusts bottom clipping && EngineViewParent position doesn't change`() {
+ val engineView: EngineView = spy(FakeEngineView(testContext))
+ val engineParentView: View = spy(View(testContext))
+ val toolbar: BrowserToolbar = mock()
+ doReturn(Y_DOWN_TRANSITION).`when`(toolbar).translationY
+
+ assertEquals(0f, engineParentView.translationY)
+
+ EngineViewClippingBehavior(
+ context = mock(),
+ attrs = null,
+ engineViewParent = engineParentView,
+ topToolbarHeight = 0,
+ ).apply {
+ this.engineView = engineView
+ }.run {
+ onDependentViewChanged(mock(), mock(), toolbar)
+ }
+
+ // We want to position the engine view popup content
+ // right above the bottom toolbar when the toolbar
+ // is being shifted down. The top of the bottom toolbar
+ // is either positive or zero, but for clipping
+ // the values should be negative because the baseline
+ // for clipping is bottom toolbar height.
+ val bottomClipping = -Y_DOWN_TRANSITION.toInt()
+ verify(engineView).setVerticalClipping(bottomClipping)
+
+ assertEquals(0f, engineParentView.translationY)
+ }
+
+ @Test
+ fun `GIVEN the toolbar is at the bottom && the navbar is enabled WHEN toolbar is being shifted THEN EngineView adjusts bottom clipping && EngineViewParent position doesn't change`() {
+ val engineView: EngineView = spy(FakeEngineView(testContext))
+ val engineParentView: View = spy(View(testContext))
+ val toolbar: ToolbarContainerView = mock()
+ doReturn(Y_DOWN_TRANSITION).`when`(toolbar).translationY
+
+ assertEquals(0f, engineParentView.translationY)
+
+ EngineViewClippingBehavior(
+ context = mock(),
+ attrs = null,
+ engineViewParent = engineParentView,
+ topToolbarHeight = 0,
+ ).apply {
+ this.engineView = engineView
+ }.run {
+ onDependentViewChanged(mock(), mock(), toolbar)
+ }
+
+ // We want to position the engine view popup content
+ // right above the bottom toolbar when the toolbar
+ // is being shifted down. The top of the bottom toolbar
+ // is either positive or zero, but for clipping
+ // the values should be negative because the baseline
+ // for clipping is bottom toolbar height.
+ val bottomClipping = -Y_DOWN_TRANSITION.toInt()
+ verify(engineView).setVerticalClipping(bottomClipping)
+
+ assertEquals(0f, engineParentView.translationY)
+ }
+
+ // Top toolbar position tests
+ @Test
+ fun `GIVEN the toolbar is at the top WHEN toolbar is being shifted THEN EngineView adjusts bottom clipping && EngineViewParent shifts as well`() {
+ val engineView: EngineView = spy(FakeEngineView(testContext))
+ val engineParentView: View = spy(View(testContext))
+ val toolbar: BrowserToolbar = mock()
+ doReturn(Y_UP_TRANSITION).`when`(toolbar).translationY
+
+ assertEquals(0f, engineParentView.translationY)
+
+ EngineViewClippingBehavior(
+ context = mock(),
+ attrs = null,
+ engineViewParent = engineParentView,
+ topToolbarHeight = TOOLBAR_HEIGHT.toInt(),
+ ).apply {
+ this.engineView = engineView
+ }.run {
+ onDependentViewChanged(mock(), mock(), toolbar)
+ }
+
+ verify(engineView).setVerticalClipping(Y_UP_TRANSITION.toInt())
+
+ // Here we are adjusting the vertical position of
+ // the engine view container to be directly under
+ // the toolbar. The top toolbar is shifting up, so
+ // its translation will be either negative or zero.
+ val bottomClipping = Y_UP_TRANSITION + TOOLBAR_HEIGHT
+ assertEquals(bottomClipping, engineParentView.translationY)
+ }
+
+ // Combined toolbar position tests
+ @Test
+ fun `WHEN both of the toolbars are being shifted GIVEN the toolbar is at the top && the navbar is enabled THEN EngineView adjusts bottom clipping`() {
+ val engineView: EngineView = spy(FakeEngineView(testContext))
+ val engineParentView: View = spy(View(testContext))
+ val toolbar: BrowserToolbar = mock()
+ val toolbarContainerView: ToolbarContainerView = mock()
+ doReturn(Y_UP_TRANSITION).`when`(toolbar).translationY
+ doReturn(Y_DOWN_TRANSITION).`when`(toolbarContainerView).translationY
+
+ EngineViewClippingBehavior(
+ context = mock(),
+ attrs = null,
+ engineViewParent = engineParentView,
+ topToolbarHeight = TOOLBAR_HEIGHT.toInt(),
+ ).apply {
+ this.engineView = engineView
+ }.run {
+ onDependentViewChanged(mock(), mock(), toolbar)
+ onDependentViewChanged(mock(), mock(), toolbarContainerView)
+ }
+
+ val doubleClipping = Y_UP_TRANSITION - Y_DOWN_TRANSITION
+ verify(engineView).setVerticalClipping(doubleClipping.toInt())
+ }
+
+ @Test
+ fun `WHEN both of the toolbars are being shifted GIVEN the toolbar is at the top && the navbar is enabled THEN EngineViewParent shifts as well`() {
+ val engineView: EngineView = spy(FakeEngineView(testContext))
+ val engineParentView: View = spy(View(testContext))
+ val toolbar: BrowserToolbar = mock()
+ val toolbarContainerView: ToolbarContainerView = mock()
+ doReturn(Y_UP_TRANSITION).`when`(toolbar).translationY
+ doReturn(Y_DOWN_TRANSITION).`when`(toolbarContainerView).translationY
+
+ EngineViewClippingBehavior(
+ context = mock(),
+ attrs = null,
+ engineViewParent = engineParentView,
+ topToolbarHeight = TOOLBAR_HEIGHT.toInt(),
+ ).apply {
+ this.engineView = engineView
+ }.run {
+ onDependentViewChanged(mock(), mock(), toolbar)
+ onDependentViewChanged(mock(), mock(), toolbarContainerView)
+ }
+
+ // The top of the parent should be positioned right below the toolbar,
+ // so when we are given the new Y position of the top of the toolbar,
+ // which is always negative as the element is being "scrolled" out of
+ // the screen, the bottom of the toolbar is just a toolbar height away
+ // from it.
+ val parentTranslation = Y_UP_TRANSITION + TOOLBAR_HEIGHT
+ assertEquals(parentTranslation, engineParentView.translationY)
+ }
+
+ // Edge cases
+ @Test
+ fun `GIVEN top toolbar is much bigger than bottom WHEN bottom stopped shifting && top is shifting THEN bottom clipping && engineParentView shifting is still accurate`() {
+ val largeYUpTransition = -500f
+ val largeTopToolbarHeight = 500
+ val engineView: EngineView = spy(FakeEngineView(testContext))
+ val engineParentView: View = spy(View(testContext))
+ val toolbar: BrowserToolbar = mock()
+ doReturn(largeYUpTransition).`when`(toolbar).translationY
+
+ EngineViewClippingBehavior(
+ context = mock(),
+ attrs = null,
+ engineViewParent = engineParentView,
+ topToolbarHeight = largeTopToolbarHeight,
+ ).apply {
+ this.engineView = engineView
+ this.recentBottomToolbarTranslation = Y_DOWN_TRANSITION
+ }.run {
+ onDependentViewChanged(mock(), mock(), toolbar)
+ }
+
+ val doubleClipping = largeYUpTransition - Y_DOWN_TRANSITION
+ verify(engineView).setVerticalClipping(doubleClipping.toInt())
+
+ val parentTranslation = largeYUpTransition + largeTopToolbarHeight
+ assertEquals(parentTranslation, engineParentView.translationY)
+ }
+
+ @Test
+ fun `GIVEN bottom toolbar is much bigger than top WHEN top stopped shifting && bottom is shifting THEN bottom clipping && engineParentView shifting is still accurate`() {
+ val largeYBottomTransition = 500f
+ val engineView: EngineView = spy(FakeEngineView(testContext))
+ val engineParentView: View = spy(View(testContext))
+ val toolbarContainerView: ToolbarContainerView = mock()
+ doReturn(largeYBottomTransition).`when`(toolbarContainerView).translationY
+
+ EngineViewClippingBehavior(
+ context = mock(),
+ attrs = null,
+ engineViewParent = engineParentView,
+ topToolbarHeight = TOOLBAR_HEIGHT.toInt(),
+ ).apply {
+ this.engineView = engineView
+ this.recentTopToolbarTranslation = Y_UP_TRANSITION
+ }.run {
+ onDependentViewChanged(mock(), mock(), toolbarContainerView)
+ }
+
+ val doubleClipping = Y_UP_TRANSITION - largeYBottomTransition
+ verify(engineView).setVerticalClipping(doubleClipping.toInt())
+
+ val parentTranslation = Y_UP_TRANSITION + TOOLBAR_HEIGHT
+ assertEquals(parentTranslation, engineParentView.translationY)
+ }
+
+ @Test
+ fun `GIVEN a bottom toolbar WHEN translation returns NaN THEN no exception thrown`() {
+ val engineView: EngineView = spy(FakeEngineView(testContext))
+ val engineParentView: View = spy(View(testContext))
+ val toolbar: View = mock()
+ doReturn(100).`when`(toolbar).height
+ doReturn(Float.NaN).`when`(toolbar).translationY
+
+ EngineViewClippingBehavior(
+ context = mock(),
+ attrs = null,
+ engineViewParent = engineParentView,
+ topToolbarHeight = 0,
+ ).apply {
+ this.engineView = engineView
+ }.run {
+ onDependentViewChanged(mock(), mock(), toolbar)
+ }
+
+ assertEquals(0f, engineView.asView().translationY)
+ }
+
+ // General tests
+ @Test
+ fun `WHEN layoutDependsOn receives a class that isn't a ScrollableToolbar THEN it ignores it`() {
+ val behavior = EngineViewClippingBehavior(
+ context = mock(),
+ attrs = null,
+ engineViewParent = mock(),
+ topToolbarHeight = 0,
+ )
+
+ assertFalse(behavior.layoutDependsOn(mock(), mock(), TextView(testContext)))
+ assertFalse(behavior.layoutDependsOn(mock(), mock(), EditText(testContext)))
+ assertFalse(behavior.layoutDependsOn(mock(), mock(), ImageView(testContext)))
+ }
+
+ @Test
+ fun `WHEN layoutDependsOn receives a class that is a ScrollableToolbar THEN it recognizes it as a dependency`() {
+ val behavior = EngineViewClippingBehavior(
+ context = mock(),
+ attrs = null,
+ engineViewParent = mock(),
+ topToolbarHeight = 0,
+ )
+
+ assertTrue(behavior.layoutDependsOn(mock(), mock(), BrowserToolbar(testContext)))
+ assertTrue(behavior.layoutDependsOn(mock(), mock(), ToolbarContainerView(testContext)))
+ }
+}
+
+private const val TOOLBAR_HEIGHT = 100f
+private const val Y_UP_TRANSITION = -42f
+private const val Y_DOWN_TRANSITION = -42f
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/MenuPresenterTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/MenuPresenterTest.kt
new file mode 100644
index 0000000000..46e111c2b0
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/MenuPresenterTest.kt
@@ -0,0 +1,83 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.toolbar
+
+import io.mockk.clearMocks
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.browser.toolbar.BrowserToolbar
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class MenuPresenterTest {
+
+ private lateinit var store: BrowserStore
+ private lateinit var testTab: TabSessionState
+ private lateinit var menuPresenter: MenuPresenter
+ private lateinit var menuToolbar: BrowserToolbar
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Before
+ fun setup() {
+ testTab = createTab(url = "https://mozilla.org")
+ store = BrowserStore(initialState = BrowserState(tabs = listOf(testTab), selectedTabId = testTab.id))
+ menuToolbar = mockk(relaxed = true)
+ menuPresenter = MenuPresenter(menuToolbar, store).also {
+ it.start()
+ }
+ clearMocks(menuToolbar)
+ }
+
+ @Test
+ fun `WHEN loading state is updated THEN toolbar is invalidated`() {
+ verify(exactly = 0) { menuToolbar.invalidateActions() }
+
+ store.dispatch(ContentAction.UpdateLoadingStateAction(testTab.id, true)).joinBlocking()
+ verify(exactly = 1) { menuToolbar.invalidateActions() }
+
+ store.dispatch(ContentAction.UpdateLoadingStateAction(testTab.id, false)).joinBlocking()
+ verify(exactly = 2) { menuToolbar.invalidateActions() }
+ }
+
+ @Test
+ fun `WHEN back navigation state is updated THEN toolbar is invalidated`() {
+ verify(exactly = 0) { menuToolbar.invalidateActions() }
+
+ store.dispatch(ContentAction.UpdateBackNavigationStateAction(testTab.id, true)).joinBlocking()
+ verify(exactly = 1) { menuToolbar.invalidateActions() }
+
+ store.dispatch(ContentAction.UpdateBackNavigationStateAction(testTab.id, false)).joinBlocking()
+ verify(exactly = 2) { menuToolbar.invalidateActions() }
+ }
+
+ @Test
+ fun `WHEN forward navigation state is updated THEN toolbar is invalidated`() {
+ verify(exactly = 0) { menuToolbar.invalidateActions() }
+
+ store.dispatch(ContentAction.UpdateForwardNavigationStateAction(testTab.id, true)).joinBlocking()
+ verify(exactly = 1) { menuToolbar.invalidateActions() }
+
+ store.dispatch(ContentAction.UpdateForwardNavigationStateAction(testTab.id, false)).joinBlocking()
+ verify(exactly = 2) { menuToolbar.invalidateActions() }
+ }
+
+ @Test
+ fun `WHEN web app manifest is updated THEN toolbar is invalidated`() {
+ verify(exactly = 0) { menuToolbar.invalidateActions() }
+
+ store.dispatch(ContentAction.UpdateWebAppManifestAction(testTab.id, mockk())).joinBlocking()
+ verify(exactly = 1) { menuToolbar.invalidateActions() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/NavbarIntegrationTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/NavbarIntegrationTest.kt
new file mode 100644
index 0000000000..a402cdbfa0
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/NavbarIntegrationTest.kt
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.toolbar
+
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.components.toolbar.navbar.NavbarIntegration
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class NavbarIntegrationTest {
+ private lateinit var feature: NavbarIntegration
+
+ @Before
+ fun setup() {
+ feature = NavbarIntegration(
+ toolbar = mockk(),
+ store = mockk(),
+ appStore = mockk(),
+ bottomToolbarContainerView = mockk(),
+ sessionId = null,
+ ).apply {
+ toolbarController = mockk(relaxed = true)
+ }
+ }
+
+ @Test
+ fun `WHEN the feature starts THEN toolbar controllers starts as well`() {
+ feature.start()
+
+ verify { feature.toolbarController.start() }
+ }
+
+ @Test
+ fun `WHEN the feature stops THEN toolbar controllers stops as well`() {
+ feature.stop()
+
+ verify { feature.toolbarController.stop() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/TabCounterMenuTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/TabCounterMenuTest.kt
new file mode 100644
index 0000000000..a9316f266e
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/TabCounterMenuTest.kt
@@ -0,0 +1,70 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.components.toolbar
+
+import android.content.Context
+import androidx.appcompat.view.ContextThemeWrapper
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.concept.menu.candidate.DividerMenuCandidate
+import mozilla.components.concept.menu.candidate.TextMenuCandidate
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.ui.tabcounter.TabCounterMenu
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.browser.browsingmode.BrowsingMode
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class TabCounterMenuTest {
+
+ private lateinit var context: Context
+ private lateinit var onItemTapped: (TabCounterMenu.Item) -> Unit
+ private lateinit var menu: FenixTabCounterMenu
+
+ @Before
+ fun setup() {
+ context = ContextThemeWrapper(testContext, R.style.NormalTheme)
+ onItemTapped = mockk(relaxed = true)
+ menu = FenixTabCounterMenu(context, onItemTapped)
+ }
+
+ @Test
+ fun `return only the new tab item`() {
+ val items = menu.menuItems(showOnly = BrowsingMode.Normal)
+ assertEquals(1, items.size)
+
+ val item = items[0] as TextMenuCandidate
+ assertEquals("New tab", item.text)
+ item.onClick()
+
+ verify { onItemTapped(TabCounterMenu.Item.NewTab) }
+ }
+
+ @Test
+ fun `return only the new private tab item`() {
+ val items = menu.menuItems(showOnly = BrowsingMode.Private)
+ assertEquals(1, items.size)
+
+ val item = items[0] as TextMenuCandidate
+ assertEquals("New private tab", item.text)
+ item.onClick()
+
+ verify { onItemTapped(TabCounterMenu.Item.NewPrivateTab) }
+ }
+
+ @Test
+ fun `return two new tab items and a close button`() {
+ val (newTab, newPrivateTab, divider, closeTab) = menu.menuItems(ToolbarPosition.TOP)
+
+ assertEquals("New tab", (newTab as TextMenuCandidate).text)
+ assertEquals("New private tab", (newPrivateTab as TextMenuCandidate).text)
+ assertEquals("Close tab", (closeTab as TextMenuCandidate).text)
+ assertEquals(DividerMenuCandidate(), divider)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/compose/LinkTextTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/compose/LinkTextTest.kt
new file mode 100644
index 0000000000..34ab686dcb
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/compose/LinkTextTest.kt
@@ -0,0 +1,76 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.compose
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.style.TextDecoration
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class LinkTextTest {
+
+ @Test
+ fun `WHEN attempting a click on non-annotated text THEN no links are clicked`() {
+ var linkClicked = false
+ val linkTextState = LinkTextState(
+ text = "clickable text",
+ url = "www.mozilla.com",
+ onClick = { linkClicked = true },
+ )
+ val annotatedString = buildUrlAnnotatedString(
+ text = "This is not clickable text, but this is ${linkTextState.text}",
+ linkTextStates = listOf(linkTextState),
+ color = Color(0xFF000000),
+ decoration = TextDecoration.None,
+ )
+
+ onTextClick(
+ annotatedString,
+ 2,
+ listOf(linkTextState),
+ )
+
+ assertFalse(linkClicked)
+ }
+
+ @Test
+ fun `WHEN attempting a click on clickable annotated text THEN the corresponding link is clicked`() {
+ val linkClicked = mutableListOf(false, false, false)
+ val linkTextStates = listOf(
+ LinkTextState(
+ text = "click1",
+ url = "www.mozilla.com",
+ onClick = { linkClicked[0] = true },
+ ),
+ LinkTextState(
+ text = "click2",
+ url = "www.mozilla.com",
+ onClick = { linkClicked[1] = true },
+ ),
+ LinkTextState(
+ text = "click3",
+ url = "www.mozilla.com",
+ onClick = { linkClicked[2] = true },
+ ),
+ )
+ val annotatedString = buildUrlAnnotatedString(
+ text = "This is not clickable text, but these are clickable texts: " + linkTextStates.map { it.text + ", " },
+ linkTextStates = linkTextStates,
+ color = Color(0xFF000000),
+ decoration = TextDecoration.None,
+ )
+
+ onTextClick(
+ annotatedString,
+ 70,
+ linkTextStates,
+ )
+
+ assertFalse(linkClicked[0])
+ assertFalse(linkClicked[2])
+ assertTrue(linkClicked[1])
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/compose/ext/IntTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/compose/ext/IntTest.kt
new file mode 100644
index 0000000000..c7c68f7ebd
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/compose/ext/IntTest.kt
@@ -0,0 +1,22 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.compose.ext
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.util.Locale as JavaLocale
+
+class IntTest {
+
+ @Test
+ fun `WHEN the language is Arabic THEN translate the number to the proper symbol of that locale`() {
+ val expected = "Ù¥"
+ val numberUnderTest = 5
+
+ JavaLocale.setDefault(JavaLocale("ar"))
+
+ assertEquals(expected, numberUnderTest.toLocaleString())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/compose/ext/ModifierTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/compose/ext/ModifierTest.kt
new file mode 100644
index 0000000000..662e3e8e8b
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/compose/ext/ModifierTest.kt
@@ -0,0 +1,33 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.compose.ext
+
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ModifierTest {
+ @Test
+ fun `GIVEN predicate is false WHEN thenConditional called THEN the original modifier is returned`() {
+ val modifier = Modifier.height(1.dp)
+
+ assertEquals(modifier, modifier.thenConditional(Modifier.width(1.dp)) { false })
+ }
+
+ @Test
+ fun `GIVEN predicate is true WHEN thenConditional called THEN the updated modifier is returned`() {
+ val modifier = Modifier.height(1.dp)
+
+ val expected = Modifier
+ .height(1.dp)
+ .width(1.dp)
+ val actual = modifier.thenConditional(Modifier.width(1.dp)) { true }
+
+ assertEquals(expected, actual)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/crashes/CrashContentIntegrationTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/crashes/CrashContentIntegrationTest.kt
new file mode 100644
index 0000000000..d7b0f109ac
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/crashes/CrashContentIntegrationTest.kt
@@ -0,0 +1,108 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.crashes
+
+import android.view.ViewGroup.MarginLayoutParams
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import mozilla.components.browser.state.action.CrashAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.browser.toolbar.BrowserToolbar
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.Components
+import org.mozilla.fenix.utils.Settings
+
+class CrashContentIntegrationTest {
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ private val sessionId = "sessionId"
+ private lateinit var browserStore: BrowserStore
+
+ @Before
+ fun setup() {
+ browserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab("url", id = sessionId),
+ ),
+ selectedTabId = sessionId,
+ ),
+ )
+ }
+
+ @Test
+ fun `GIVEN a tab WHEN its content crashes THEN expand the toolbar and show the in-content crash reporter`() {
+ val crashReporterLayoutParams: MarginLayoutParams = mockk(relaxed = true)
+ val crashReporterView: CrashContentView = mockk(relaxed = true) {
+ every { layoutParams } returns crashReporterLayoutParams
+ }
+ val toolbar: BrowserToolbar = mockk(relaxed = true) {
+ every { height } returns 33
+ }
+ val components: Components = mockk()
+ val settings: Settings = mockk()
+ val appStore: AppStore = mockk()
+ val integration = CrashContentIntegration(
+ browserStore = browserStore,
+ appStore = appStore,
+ toolbar = toolbar,
+ isToolbarPlacedAtTop = true,
+ crashReporterView = crashReporterView,
+ components = components,
+ settings = settings,
+ navController = mockk(),
+ sessionId = sessionId,
+ )
+ val controllerCaptor = slot<CrashReporterController>()
+ integration.start()
+ browserStore.dispatch(CrashAction.SessionCrashedAction(sessionId))
+ browserStore.waitUntilIdle()
+
+ verify {
+ toolbar.expand()
+ crashReporterLayoutParams.topMargin = 33
+ crashReporterView.show(capture(controllerCaptor))
+ }
+ assertEquals(sessionId, controllerCaptor.captured.sessionId)
+ assertEquals(components, controllerCaptor.captured.components)
+ assertEquals(settings, controllerCaptor.captured.settings)
+ assertEquals(appStore, controllerCaptor.captured.appStore)
+ }
+
+ @Test
+ fun `GIVEN a tab is marked as crashed WHEN the crashed state changes THEN hide the in-content crash reporter`() {
+ val crashReporterView: CrashContentView = mockk(relaxed = true)
+ val integration = CrashContentIntegration(
+ browserStore = browserStore,
+ appStore = mockk(),
+ toolbar = mockk(),
+ isToolbarPlacedAtTop = true,
+ crashReporterView = crashReporterView,
+ components = mockk(),
+ settings = mockk(),
+ navController = mockk(),
+ sessionId = sessionId,
+ )
+
+ integration.start()
+ browserStore.dispatch(CrashAction.RestoreCrashedSessionAction(sessionId))
+ browserStore.waitUntilIdle()
+
+ verify { crashReporterView.hide() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/crashes/CrashContentViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/crashes/CrashContentViewTest.kt
new file mode 100644
index 0000000000..ca954426a6
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/crashes/CrashContentViewTest.kt
@@ -0,0 +1,101 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.crashes
+
+import android.view.View.GONE
+import android.view.View.VISIBLE
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.crashes.CrashContentView.Companion.TAP_INCREASE_DP
+import org.mozilla.fenix.ext.increaseTapArea
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class CrashContentViewTest {
+ @Test
+ fun `WHEN show is called THEN remember the controller, inflate and display the View`() {
+ val view = spyk(CrashContentView(testContext))
+ val controller: CrashReporterController = mockk()
+
+ view.show(controller)
+
+ assertTrue(view.controller === controller)
+ verify {
+ view.inflateViewIfNecessary()
+ view.visibility = VISIBLE
+ }
+ }
+
+ @Test
+ fun `WHEN hide is called THEN remove the View from layout`() {
+ val view = spyk(CrashContentView(testContext))
+
+ view.hide()
+
+ verify { view.visibility = GONE }
+ }
+
+ @Test
+ fun `GIVEN the View is not shown WHEN needing to be shown THEN inflate the layout and bind all widgets`() {
+ val controller: CrashReporterController = mockk(relaxed = true)
+ val view = CrashContentView(testContext)
+ view.controller = controller
+ assertFalse(view.isBindingInitialized)
+
+ mockkStatic("org.mozilla.fenix.ext.ViewKt") {
+ view.inflateViewIfNecessary()
+
+ assertTrue(view.isBindingInitialized)
+ assertEquals(
+ testContext.getString(R.string.tab_crash_title_2, testContext.getString(R.string.app_name)),
+ view.binding.title.text,
+ )
+ verify {
+ view.binding.restoreTabButton.increaseTapArea(TAP_INCREASE_DP)
+ view.binding.closeTabButton.increaseTapArea(TAP_INCREASE_DP)
+ }
+
+ view.binding.sendCrashCheckbox.isChecked = true
+ view.binding.restoreTabButton.callOnClick()
+ verify { controller.handleCloseAndRestore(true) }
+
+ view.binding.sendCrashCheckbox.isChecked = false
+ view.binding.closeTabButton.callOnClick()
+ verify { controller.handleCloseAndRemove(false) }
+ }
+ }
+
+ @Test
+ fun `GIVEN the View is not shown WHEN needing to be shown THEN delegate the process to helper methods`() {
+ val view = spyk(CrashContentView(testContext))
+
+ view.inflateViewIfNecessary()
+
+ verify {
+ view.inflate()
+ view.bindViews()
+ }
+ }
+
+ @Test
+ fun `GIVEN the View is to already shown WHEN needing to be shown again THEN return early and avoid duplicating the widgets setup`() {
+ val view = spyk(CrashContentView(testContext))
+ view.inflate() // mock that the View is already inflated
+
+ view.inflateViewIfNecessary() // try inflating it again
+
+ verify(exactly = 1) { view.inflate() }
+ verify(exactly = 0) { view.bindViews() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/crashes/CrashReporterControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/crashes/CrashReporterControllerTest.kt
new file mode 100644
index 0000000000..9b250a21bc
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/crashes/CrashReporterControllerTest.kt
@@ -0,0 +1,138 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.crashes
+
+import androidx.navigation.NavController
+import io.mockk.Called
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.lib.crash.Crash.NativeCodeCrash
+import mozilla.components.support.test.ext.joinBlocking
+import org.junit.Test
+import org.mozilla.fenix.browser.BrowserFragmentDirections
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.Components
+import org.mozilla.fenix.components.appstate.AppAction
+import org.mozilla.fenix.components.appstate.AppState
+import org.mozilla.fenix.utils.Settings
+
+class CrashReporterControllerTest {
+
+ private val sessionId = "testId"
+ private val components: Components = mockk(relaxed = true)
+ private val settings: Settings = mockk(relaxed = true)
+ private val navController: NavController = mockk(relaxed = true)
+ private val crash: NativeCodeCrash = mockk(relaxed = true)
+ private var appStore = AppStore(
+ AppState(
+ nonFatalCrashes = listOf(crash),
+ ),
+ )
+ private var controller = CrashReporterController(sessionId, 2, components, settings, navController, appStore)
+
+ @Test
+ fun `GIVEN reportCrashes true WHEN user restores tab THEN try submitting non-fatal crashes and recover tabs`() {
+ controller = spyk(controller)
+
+ controller.handleCloseAndRestore(true)
+
+ verify { controller.submitPendingNonFatalCrashesIfNecessary(true) }
+ verify { components.useCases.sessionUseCases.crashRecovery.invoke() }
+ }
+
+ @Test
+ fun `GIVEN reportCrashes false WHEN user restores tab THEN try submitting non-fatal crashes and recover tabs`() {
+ controller = spyk(controller)
+
+ controller.handleCloseAndRestore(false)
+
+ verify { controller.submitPendingNonFatalCrashesIfNecessary(false) }
+ verify { components.useCases.sessionUseCases.crashRecovery.invoke() }
+ }
+
+ @Test
+ fun `GIVEN reportCrashes true WHEN user closes the tab THEN try submitting non-fatal crashes, remove the current tab and recover others`() {
+ controller = spyk(controller)
+
+ controller.handleCloseAndRemove(true)
+
+ verify { controller.submitPendingNonFatalCrashesIfNecessary(true) }
+ verify { components.useCases.tabsUseCases.removeTab(sessionId) }
+ verify { components.useCases.sessionUseCases.crashRecovery.invoke() }
+ }
+
+ @Test
+ fun `GIVEN reportCrashes false WHEN user closes the tab THEN try submitting non-fatal crashes, remove the current tab and recover others`() {
+ controller = spyk(controller)
+
+ controller.handleCloseAndRemove(false)
+
+ verify { controller.submitPendingNonFatalCrashesIfNecessary(false) }
+ verify { components.useCases.tabsUseCases.removeTab(sessionId) }
+ verify { components.useCases.sessionUseCases.crashRecovery.invoke() }
+ }
+
+ @Test
+ fun `GIVEN reportCrashes false WHEN trying to submit crashes THEN no crashes should be submitted and all should be disposed off`() {
+ val enabledCrashReporterSettings: Settings = mockk {
+ every { isCrashReportingEnabled } returns true
+ }
+ appStore = spyk(appStore)
+ controller = CrashReporterController(sessionId, 2, components, enabledCrashReporterSettings, navController, appStore)
+
+ controller.submitPendingNonFatalCrashesIfNecessary(false)?.joinBlocking()
+
+ verify(exactly = 0) { components.analytics.crashReporter.submitReport(crash) }
+ verify { appStore.dispatch(AppAction.RemoveAllNonFatalCrashes) }
+ }
+
+ @Test
+ fun `GIVEN reportCrashes true but reporting crashes disabled WHEN trying to submit crashes THEN no crashes should be submitted and all should be disposed off`() {
+ val disabledCrashReporterSettings: Settings = mockk {
+ every { isCrashReportingEnabled } returns false
+ }
+ appStore = spyk(appStore)
+ controller = CrashReporterController(sessionId, 2, components, disabledCrashReporterSettings, navController, appStore)
+
+ controller.submitPendingNonFatalCrashesIfNecessary(true)?.joinBlocking()
+
+ verify(exactly = 0) { components.analytics.crashReporter.submitReport(crash) }
+ verify { appStore.dispatch(AppAction.RemoveAllNonFatalCrashes) }
+ }
+
+ @Test
+ fun `GIVEN reportCrashes true and reporting crashes enabled WHEN trying to submit crashes THEN all crashes should be submitted and then disposed off`() {
+ val disabledCrashReporterSettings: Settings = mockk {
+ every { isCrashReportingEnabled } returns true
+ }
+ appStore = spyk(appStore)
+ controller = CrashReporterController(sessionId, 2, components, disabledCrashReporterSettings, navController, appStore)
+
+ controller.submitPendingNonFatalCrashesIfNecessary(true)!!.joinBlocking()
+
+ verify { components.analytics.crashReporter.submitReport(crash) }
+ verify { appStore.dispatch(AppAction.RemoveNonFatalCrash(crash)) }
+ }
+
+ @Test
+ fun `GIVEN only one tab opened WHEN user closes the tab THEN navigate to Home`() {
+ controller = CrashReporterController(sessionId, 1, components, settings, navController, appStore)
+
+ controller.handleCloseAndRemove(true)
+
+ verify { navController.navigate(BrowserFragmentDirections.actionGlobalHome()) }
+ }
+
+ @Test
+ fun `GIVEN multiple tabs opened WHEN user closes one tab THEN don't use navigation`() {
+ controller = CrashReporterController(sessionId, 2, components, settings, navController, appStore)
+
+ controller.handleCloseAndRemove(true)
+
+ verify { navController wasNot Called }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/customtabs/CustomTabToolbarMenuTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/customtabs/CustomTabToolbarMenuTest.kt
new file mode 100644
index 0000000000..7f2279e7fd
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/customtabs/CustomTabToolbarMenuTest.kt
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.customtabs
+
+import android.content.Context
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.CustomTabSessionState
+import mozilla.components.browser.state.state.createCustomTab
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.ext.settings
+
+class CustomTabToolbarMenuTest {
+
+ private lateinit var firefoxCustomTab: CustomTabSessionState
+ private lateinit var store: BrowserStore
+ private lateinit var customTabToolbarMenu: CustomTabToolbarMenu
+ private lateinit var context: Context
+
+ @Before
+ fun setUp() {
+ context = mockk(relaxed = true)
+ every { context.settings() } returns mockk(relaxed = true)
+
+ firefoxCustomTab = createCustomTab(url = "https://firefox.com", id = "123")
+
+ store = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab(url = "https://wikipedia.com", id = "1"),
+ ),
+ customTabs = listOf(
+ firefoxCustomTab,
+ createCustomTab(url = "https://mozilla.com", id = "456"),
+ ),
+ ),
+ )
+
+ customTabToolbarMenu = spyk(
+ CustomTabToolbarMenu(
+ context = context,
+ store = store,
+ sessionId = firefoxCustomTab.id,
+ shouldReverseItems = false,
+ isSandboxCustomTab = false,
+ onItemTapped = { },
+ ),
+ )
+ }
+
+ @Test
+ fun `custom tab toolbar menu uses the proper custom tab session`() {
+ assertEquals(firefoxCustomTab.id, customTabToolbarMenu.session?.id)
+ assertEquals("https://firefox.com", customTabToolbarMenu.session?.content?.url)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/customtabs/ExternalAppBrowserActivityTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/customtabs/ExternalAppBrowserActivityTest.kt
new file mode 100644
index 0000000000..4f9abfaac2
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/customtabs/ExternalAppBrowserActivityTest.kt
@@ -0,0 +1,239 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.customtabs
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.navigation.NavController
+import io.mockk.Called
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createCustomTab
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.feature.intent.ext.putSessionId
+import mozilla.components.support.utils.toSafeIntent
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.BrowserDirection
+import org.mozilla.fenix.NavGraphDirections
+import org.mozilla.fenix.browser.browsingmode.BrowsingMode
+import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.getIntentSource
+import org.mozilla.fenix.ext.getNavDirections
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class ExternalAppBrowserActivityTest {
+
+ @Test
+ fun getIntentSource() {
+ val activity = ExternalAppBrowserActivity()
+
+ val launcherIntent = Intent(Intent.ACTION_MAIN).apply {
+ addCategory(Intent.CATEGORY_LAUNCHER)
+ }.toSafeIntent()
+ assertEquals("CUSTOM_TAB", activity.getIntentSource(launcherIntent))
+
+ val viewIntent = Intent(Intent.ACTION_VIEW).toSafeIntent()
+ assertEquals("CUSTOM_TAB", activity.getIntentSource(viewIntent))
+
+ val otherIntent = Intent().toSafeIntent()
+ assertEquals("CUSTOM_TAB", activity.getIntentSource(otherIntent))
+ }
+
+ @Test
+ fun `navigateToBrowserOnColdStart does nothing for external app browser activity`() {
+ val activity = spyk(ExternalAppBrowserActivity())
+ val browsingModeManager: BrowsingModeManager = mockk()
+ every { browsingModeManager.mode } returns BrowsingMode.Normal
+
+ val settings: Settings = mockk()
+ every { settings.shouldReturnToBrowser } returns true
+ every { activity.components.settings.shouldReturnToBrowser } returns true
+ every { activity.openToBrowser(any(), any()) } returns Unit
+
+ activity.browsingModeManager = browsingModeManager
+ activity.navigateToBrowserOnColdStart()
+
+ verify(exactly = 0) { activity.openToBrowser(BrowserDirection.FromGlobal, null) }
+ }
+
+ @Test
+ fun `navigateToHome does nothing for external app browser activity`() {
+ val activity = spyk(ExternalAppBrowserActivity())
+ val navHostController: NavController = mockk()
+
+ activity.navigateToHome(navHostController)
+ verify { navHostController wasNot Called }
+ }
+
+ @Test
+ fun `handleNewIntent does nothing for external app browser activity`() {
+ val activity = spyk(ExternalAppBrowserActivity())
+ val intent: Intent = mockk(relaxed = true)
+
+ activity.handleNewIntent(intent)
+ verify { intent wasNot Called }
+ }
+
+ @Test
+ fun `getNavDirections finishes activity if session ID is null`() {
+ val activity = spyk(
+ object : ExternalAppBrowserActivity() {
+ override fun getIntent(): Intent {
+ val intent: Intent = mockk()
+ val bundle: Bundle = mockk()
+ every { bundle.getString(any()) } returns ""
+ every { intent.extras } returns bundle
+ every { intent.getBooleanExtra(any(), any()) } returns false
+ return intent
+ }
+ },
+ )
+
+ var directions = activity.getNavDirections(BrowserDirection.FromGlobal, "id")
+ assertNotNull(directions)
+ verify(exactly = 0) { activity.finishAndRemoveTask() }
+
+ directions = activity.getNavDirections(BrowserDirection.FromGlobal, null)
+ assertNull(directions)
+ verify { activity.finishAndRemoveTask() }
+ }
+
+ @Test
+ fun `GIVEN intent isSandboxCustomTab is true WHEN getNavDirections called THEN actionGlobalExternalAppBrowser isSandboxCustomTab is true`() {
+ val activity = spyk(
+ object : ExternalAppBrowserActivity() {
+ override fun getIntent(): Intent {
+ val intent: Intent = mockk()
+ val bundle: Bundle = mockk()
+ every { bundle.getString(any()) } returns ""
+ every { intent.getBooleanExtra(any(), any()) } returns true
+ every { intent.extras } returns bundle
+ return intent
+ }
+ },
+ )
+
+ val customTabSessionId = "id"
+ val directions = activity.getNavDirections(BrowserDirection.FromGlobal, customTabSessionId)
+ assertNotNull(directions)
+ verify(exactly = 0) { activity.finishAndRemoveTask() }
+
+ val expected = NavGraphDirections.actionGlobalExternalAppBrowser(
+ activeSessionId = customTabSessionId,
+ webAppManifest = null,
+ isSandboxCustomTab = true,
+ )
+ assertEquals(expected, directions)
+ }
+
+ @Test
+ fun `GIVEN intent isSandboxCustomTab is false WHEN getNavDirections called THEN actionGlobalExternalAppBrowser isSandboxCustomTab is false`() {
+ val activity = spyk(
+ object : ExternalAppBrowserActivity() {
+ override fun getIntent(): Intent {
+ val intent: Intent = mockk()
+ val bundle: Bundle = mockk()
+ every { bundle.getString(any()) } returns ""
+ every { intent.getBooleanExtra(any(), any()) } returns false
+ every { intent.extras } returns bundle
+ return intent
+ }
+ },
+ )
+
+ val customTabSessionId = "id"
+ val directions = activity.getNavDirections(BrowserDirection.FromGlobal, customTabSessionId)
+ assertNotNull(directions)
+ verify(exactly = 0) { activity.finishAndRemoveTask() }
+
+ val expected = NavGraphDirections.actionGlobalExternalAppBrowser(
+ activeSessionId = customTabSessionId,
+ webAppManifest = null,
+ isSandboxCustomTab = false,
+ )
+ assertEquals(expected, directions)
+ }
+
+ @Test
+ fun `ExternalAppBrowserActivity with matching external tab`() {
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(
+ createCustomTab(
+ url = "https://www.mozilla.org",
+ id = "mozilla",
+ ),
+ ),
+ ),
+ )
+
+ val intent = Intent(Intent.ACTION_VIEW).apply { putSessionId("mozilla") }
+
+ val activity = spyk(ExternalAppBrowserActivity())
+ every { activity.components.core.store } returns store
+ every { activity.intent } returns intent
+
+ assertTrue(activity.hasExternalTab())
+
+ assertEquals("mozilla", activity.getExternalTabId())
+
+ val tab = activity.getExternalTab()
+ assertNotNull(tab!!)
+ assertEquals("https://www.mozilla.org", tab.content.url)
+ }
+
+ @Test
+ fun `ExternalAppBrowserActivity without matching external tab`() {
+ val store = BrowserStore()
+
+ val intent = Intent(Intent.ACTION_VIEW).apply { putSessionId("mozilla") }
+
+ val activity = spyk(ExternalAppBrowserActivity())
+ every { activity.components.core.store } returns store
+ every { activity.intent } returns intent
+
+ assertFalse(activity.hasExternalTab())
+ assertEquals("mozilla", activity.getExternalTabId())
+ assertNull(activity.getExternalTab())
+ }
+
+ @Test
+ fun `ExternalAppBrowserActivity with matching regular tab`() {
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab(
+ url = "https://www.mozilla.org",
+ id = "mozilla",
+ ),
+ ),
+ ),
+ )
+
+ val intent = Intent(Intent.ACTION_VIEW).apply { putSessionId("mozilla") }
+
+ val activity = spyk(ExternalAppBrowserActivity())
+ every { activity.components.core.store } returns store
+ every { activity.intent } returns intent
+
+ // Even though we have a matching regular tab we do not care about it in ExternalAppBrowserActivity
+ assertFalse(activity.hasExternalTab())
+ assertEquals("mozilla", activity.getExternalTabId())
+ assertNull(activity.getExternalTab())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/customtabs/FennecWebAppIntentProcessorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/customtabs/FennecWebAppIntentProcessorTest.kt
new file mode 100644
index 0000000000..e088159462
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/customtabs/FennecWebAppIntentProcessorTest.kt
@@ -0,0 +1,70 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.customtabs
+
+import io.mockk.mockk
+import mozilla.components.feature.pwa.ManifestStorage
+import mozilla.components.feature.tabs.CustomTabsUseCases
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import java.io.File
+
+@RunWith(FenixRobolectricTestRunner::class)
+class FennecWebAppIntentProcessorTest {
+ @Test
+ fun `fennec manifest path - tmp`() {
+ val processor = createFennecWebAppIntentProcessor()
+
+ val file = File("/data/local/tmp/dummy_manifest.json")
+ assertFalse(processor.isUnderFennecManifestDirectory(file))
+ }
+
+ @Test
+ fun `fennec manifest path - correct path`() {
+ val processor = createFennecWebAppIntentProcessor()
+
+ val file = File(testContext.filesDir.absolutePath + "/mozilla/rkgl5eyc.default/manifests/c311ad28-f331-482f-ba8f-a0fbf2c56a0d.json")
+ assertTrue(processor.isUnderFennecManifestDirectory(file))
+ }
+
+ @Test
+ fun `fennec manifest path - correct path, but other app`() {
+ val processor = createFennecWebAppIntentProcessor()
+
+ val file = File("/data/data/org.other.app/files/mozilla/rkgl5eyc.default/manifests/c311ad28-f331-482f-ba8f-a0fbf2c56a0d.json")
+ assertFalse(processor.isUnderFennecManifestDirectory(file))
+ }
+
+ @Test
+ fun `fennec manifest path - root file`() {
+ val processor = createFennecWebAppIntentProcessor()
+
+ val file = File("/c311ad28-f331-482f-ba8f-a0fbf2c56a0d.json")
+ assertFalse(processor.isUnderFennecManifestDirectory(file))
+ }
+
+ @Test
+ fun `fennec manifest path - tmp path rebuild`() {
+ val processor = createFennecWebAppIntentProcessor()
+
+ val file = File("/data/local/tmp/files/mozilla/rkgl5eyc.default/manifests/c311ad28-f331-482f-ba8f-a0fbf2c56a0d.json")
+ assertFalse(processor.isUnderFennecManifestDirectory(file))
+ }
+}
+
+private fun createFennecWebAppIntentProcessor(): FennecWebAppIntentProcessor {
+ val useCase: CustomTabsUseCases = mockk(relaxed = true)
+ val storage: ManifestStorage = mockk(relaxed = true)
+
+ return FennecWebAppIntentProcessor(
+ testContext,
+ useCase,
+ storage,
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/customtabs/PoweredByNotificationTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/customtabs/PoweredByNotificationTest.kt
new file mode 100644
index 0000000000..384ca85088
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/customtabs/PoweredByNotificationTest.kt
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.customtabs
+
+import io.mockk.mockk
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.CustomTabConfig
+import mozilla.components.browser.state.state.ExternalAppType
+import mozilla.components.browser.state.state.createCustomTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class PoweredByNotificationTest {
+
+ @Test
+ fun `register receiver on resume`() {
+ val config = CustomTabConfig(externalAppType = ExternalAppType.TRUSTED_WEB_ACTIVITY)
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(
+ createCustomTab("https://mozilla.org", config = config),
+ ),
+ ),
+ )
+
+ val feature = PoweredByNotification(testContext, store, "session-id", mockk())
+ feature.onResume(mockk())
+ }
+
+ @Test
+ fun `don't register receiver if not in a TWA`() {
+ val config = CustomTabConfig(externalAppType = ExternalAppType.PROGRESSIVE_WEB_APP)
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(
+ createCustomTab("https://mozilla.org", config = config),
+ ),
+ ),
+ )
+
+ val feature = PoweredByNotification(testContext, store, "session-id", mockk())
+ feature.onResume(mockk())
+ }
+
+ @Test
+ fun `unregister receiver on pause`() {
+ val feature = PoweredByNotification(testContext, mockk(), "session-id", mockk())
+ feature.onPause(mockk())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/debugsettings/DebugDrawerNavigationMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/debugsettings/DebugDrawerNavigationMiddlewareTest.kt
new file mode 100644
index 0000000000..358fcf3956
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/debugsettings/DebugDrawerNavigationMiddlewareTest.kt
@@ -0,0 +1,71 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.debugsettings
+
+import androidx.navigation.NavHostController
+import io.mockk.called
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.debugsettings.navigation.DebugDrawerRoute
+import org.mozilla.fenix.debugsettings.store.DebugDrawerAction
+import org.mozilla.fenix.debugsettings.store.DebugDrawerNavigationMiddleware
+import org.mozilla.fenix.debugsettings.store.DebugDrawerStore
+import org.mozilla.fenix.debugsettings.ui.DEBUG_DRAWER_HOME_ROUTE
+
+class DebugDrawerNavigationMiddlewareTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val testCoroutineScope = coroutinesTestRule.scope
+
+ private val navController: NavHostController = mockk(relaxed = true)
+ private lateinit var store: DebugDrawerStore
+
+ @Before
+ fun setup() {
+ store = DebugDrawerStore(
+ middlewares = listOf(
+ DebugDrawerNavigationMiddleware(
+ navController = navController,
+ scope = testCoroutineScope,
+ ),
+ ),
+ )
+ }
+
+ @Test
+ fun `WHEN home is the next destination THEN the back stack is cleared and the user is returned to home`() {
+ store.dispatch(DebugDrawerAction.NavigateTo.Home).joinBlocking()
+
+ verify { navController.popBackStack(route = DEBUG_DRAWER_HOME_ROUTE, inclusive = false) }
+ }
+
+ @Test
+ fun `WHEN the tab tools screen is the next destination THEN the tab tools screen is navigated to`() {
+ store.dispatch(DebugDrawerAction.NavigateTo.TabTools).joinBlocking()
+
+ verify { navController.navigate(DebugDrawerRoute.TabTools.route) }
+ }
+
+ @Test
+ fun `WHEN the back button is pressed THEN the drawer should go back one screen`() {
+ store.dispatch(DebugDrawerAction.OnBackPressed).joinBlocking()
+
+ verify { navController.popBackStack() }
+ }
+
+ @Test
+ fun `WHEN a non-navigation action is dispatched THEN the drawer should not navigate`() {
+ store.dispatch(DebugDrawerAction.DrawerOpened).joinBlocking()
+ store.dispatch(DebugDrawerAction.DrawerClosed).joinBlocking()
+
+ verify { navController wasNot called }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/debugsettings/DebugDrawerStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/debugsettings/DebugDrawerStoreTest.kt
new file mode 100644
index 0000000000..5779c32506
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/debugsettings/DebugDrawerStoreTest.kt
@@ -0,0 +1,42 @@
+package org.mozilla.fenix.debugsettings
+
+import mozilla.components.support.test.ext.joinBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.mozilla.fenix.debugsettings.store.DebugDrawerAction
+import org.mozilla.fenix.debugsettings.store.DebugDrawerState
+import org.mozilla.fenix.debugsettings.store.DebugDrawerStore
+import org.mozilla.fenix.debugsettings.store.DrawerStatus
+
+class DebugDrawerStoreTest {
+
+ @Test
+ fun `GIVEN the drawer is closed WHEN the drawer is opened THEN the state should be set to open`() {
+ val expected = DrawerStatus.Open
+ val store = createStore()
+
+ store.dispatch(DebugDrawerAction.DrawerOpened).joinBlocking()
+
+ assertEquals(expected, store.state.drawerStatus)
+ }
+
+ @Test
+ fun `GIVEN the drawer is opened WHEN the drawer is closed THEN the state should be set to closed`() {
+ val expected = DrawerStatus.Closed
+ val store = createStore(
+ drawerStatus = DrawerStatus.Open,
+ )
+
+ store.dispatch(DebugDrawerAction.DrawerClosed).joinBlocking()
+
+ assertEquals(expected, store.state.drawerStatus)
+ }
+
+ private fun createStore(
+ drawerStatus: DrawerStatus = DrawerStatus.Closed,
+ ) = DebugDrawerStore(
+ initialState = DebugDrawerState(
+ drawerStatus = drawerStatus,
+ ),
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/debugsettings/DefaultDebugSettingsRepositoryTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/debugsettings/DefaultDebugSettingsRepositoryTest.kt
new file mode 100644
index 0000000000..8bdd86ba7e
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/debugsettings/DefaultDebugSettingsRepositoryTest.kt
@@ -0,0 +1,62 @@
+package org.mozilla.fenix.debugsettings
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.preferencesDataStore
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.test.runTest
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.debugsettings.data.DefaultDebugSettingsRepository
+
+private val Context.testDataStore: DataStore<Preferences> by preferencesDataStore(name = "DefaultDebugSettingsRepositoryTest")
+
+@RunWith(AndroidJUnit4::class)
+class DefaultDebugSettingsRepositoryTest {
+
+ @Test
+ fun `GIVEN the debug drawer is disabled WHEN the flag is enabled THEN the store should emit true`() = runTest {
+ val dataStore = testContext.testDataStore
+ val defaultDebugSettingsRepository = DefaultDebugSettingsRepository(
+ context = testContext,
+ dataStore = dataStore,
+ writeScope = this,
+ )
+ val expected = listOf(false, false, true) // First emit is from initialization
+ val expectedEmitCount = expected.size
+
+ defaultDebugSettingsRepository.setDebugDrawerEnabled(false)
+
+ defaultDebugSettingsRepository.setDebugDrawerEnabled(true)
+
+ assertEquals(expected, defaultDebugSettingsRepository.debugDrawerEnabled.take(expectedEmitCount).toList())
+
+ dataStore.edit { it.clear() }
+ }
+
+ @Test
+ fun `GIVEN the debug drawer is enabled WHEN the flag is disabled THEN the store should emit false`() = runTest {
+ val dataStore = testContext.testDataStore
+ val defaultDebugSettingsRepository = DefaultDebugSettingsRepository(
+ context = testContext,
+ dataStore = dataStore,
+ writeScope = this,
+ )
+ val expected = listOf(false, true, false) // First emit is from initialization
+ val expectedEmitCount = expected.size
+
+ defaultDebugSettingsRepository.setDebugDrawerEnabled(true)
+
+ defaultDebugSettingsRepository.setDebugDrawerEnabled(false)
+
+ assertEquals(expected, defaultDebugSettingsRepository.debugDrawerEnabled.take(expectedEmitCount).toList())
+
+ dataStore.edit { it.clear() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/downloads/DynamicDownloadDialogBehaviorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/downloads/DynamicDownloadDialogBehaviorTest.kt
new file mode 100644
index 0000000000..285a2261e1
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/downloads/DynamicDownloadDialogBehaviorTest.kt
@@ -0,0 +1,258 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.downloads
+
+import android.animation.ValueAnimator
+import android.view.View
+import androidx.core.view.ViewCompat
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.concept.engine.EngineView
+import mozilla.components.concept.engine.InputResultDetail
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class DynamicDownloadDialogBehaviorTest {
+
+ @Test
+ fun `Starting a nested scroll should cancel an ongoing snap animation`() {
+ val behavior = spyk(DynamicDownloadDialogBehavior<View>(testContext, attrs = null))
+ every { behavior.shouldScroll } returns true
+
+ val animator: ValueAnimator = mockk(relaxed = true)
+ behavior.snapAnimator = animator
+
+ val acceptsNestedScroll = behavior.onStartNestedScroll(
+ coordinatorLayout = mockk(),
+ child = mockk(),
+ directTargetChild = mockk(),
+ target = mockk(),
+ axes = ViewCompat.SCROLL_AXIS_VERTICAL,
+ type = ViewCompat.TYPE_TOUCH,
+ )
+
+ assertTrue(acceptsNestedScroll)
+
+ verify { animator.cancel() }
+ }
+
+ @Test
+ fun `Behavior should not accept nested scrolls on the horizontal axis`() {
+ val behavior = DynamicDownloadDialogBehavior<View>(testContext, attrs = null)
+
+ val acceptsNestedScroll = behavior.onStartNestedScroll(
+ coordinatorLayout = mockk(),
+ child = mockk(),
+ directTargetChild = mockk(),
+ target = mockk(),
+ axes = ViewCompat.SCROLL_AXIS_HORIZONTAL,
+ type = ViewCompat.TYPE_TOUCH,
+ )
+
+ assertFalse(acceptsNestedScroll)
+ }
+
+ @Test
+ fun `Behavior will snap the dialog up if it is more than 50 percent visible`() {
+ val behavior = spyk(
+ DynamicDownloadDialogBehavior<View>(
+ testContext,
+ attrs = null,
+ bottomToolbarHeight = 10f,
+ ),
+ )
+ every { behavior.shouldScroll } returns true
+
+ val animator: ValueAnimator = mockk(relaxed = true)
+ behavior.snapAnimator = animator
+
+ behavior.expanded = false
+
+ val child = mockk<View> {
+ every { height } returns 100
+ every { translationY } returns 59f
+ }
+
+ behavior.onStartNestedScroll(
+ coordinatorLayout = mockk(),
+ child = child,
+ directTargetChild = mockk(),
+ target = mockk(),
+ axes = ViewCompat.SCROLL_AXIS_VERTICAL,
+ type = ViewCompat.TYPE_TOUCH,
+ )
+
+ assertTrue(behavior.shouldSnapAfterScroll)
+
+ verify(exactly = 0) { animator.start() }
+
+ behavior.onStopNestedScroll(
+ coordinatorLayout = mockk(),
+ child = child,
+ target = mockk(),
+ type = 0,
+ )
+
+ verify { behavior.animateSnap(child, DynamicDownloadDialogBehavior.SnapDirection.UP) }
+
+ verify { animator.start() }
+ }
+
+ @Test
+ fun `Behavior will snap the dialog down if translationY is at least equal to half the toolbarHeight`() {
+ val behavior = spyk(
+ DynamicDownloadDialogBehavior<View>(
+ testContext,
+ attrs = null,
+ bottomToolbarHeight = 10f,
+ ),
+ )
+ every { behavior.shouldScroll } returns true
+
+ val animator: ValueAnimator = mockk(relaxed = true)
+ behavior.snapAnimator = animator
+
+ behavior.expanded = true
+
+ val child = mockk<View> {
+ every { height } returns 100
+ every { translationY } returns 5f
+ }
+
+ behavior.onStartNestedScroll(
+ coordinatorLayout = mockk(),
+ child = child,
+ directTargetChild = mockk(),
+ target = mockk(),
+ axes = ViewCompat.SCROLL_AXIS_VERTICAL,
+ type = ViewCompat.TYPE_TOUCH,
+ )
+
+ assertTrue(behavior.shouldSnapAfterScroll)
+
+ verify(exactly = 0) { animator.start() }
+
+ behavior.onStopNestedScroll(
+ coordinatorLayout = mockk(),
+ child = child,
+ target = mockk(),
+ type = 0,
+ )
+
+ verify { behavior.animateSnap(child, DynamicDownloadDialogBehavior.SnapDirection.DOWN) }
+
+ verify { animator.start() }
+ }
+
+ @Test
+ fun `Behavior will apply translation to the dialog for nested scroll`() {
+ val behavior = spyk(DynamicDownloadDialogBehavior<View>(testContext, attrs = null))
+ every { behavior.shouldScroll } returns true
+
+ val child = mockk<View> {
+ every { height } returns 100
+ every { translationY } returns 0f
+ every { translationY = any() } returns Unit
+ }
+
+ behavior.onNestedPreScroll(
+ coordinatorLayout = mockk(),
+ child = child,
+ target = mockk(),
+ dx = 0,
+ dy = 25,
+ consumed = IntArray(0),
+ type = 0,
+ )
+
+ verify { child.translationY = 25f }
+ }
+
+ @Test
+ fun `Behavior will animateSnap UP when forceExpand is called`() {
+ val behavior = spyk(DynamicDownloadDialogBehavior<View>(testContext, attrs = null))
+ val dynamicDialogView: View = mockk(relaxed = true)
+ every { behavior.shouldScroll } returns true
+
+ behavior.forceExpand(dynamicDialogView)
+
+ verify {
+ behavior.animateSnap(
+ dynamicDialogView,
+ DynamicDownloadDialogBehavior.SnapDirection.UP,
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN a null InputResultDetail from the EngineView WHEN shouldScroll is called THEN it returns false`() {
+ val behavior = DynamicDownloadDialogBehavior<View>(testContext, null, 10f)
+
+ behavior.engineView = null
+ assertFalse(behavior.shouldScroll)
+
+ behavior.engineView = mockk()
+ every { behavior.engineView?.getInputResultDetail() } returns null
+ assertFalse(behavior.shouldScroll)
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail with the right values WHEN shouldScroll is called THEN it returns true`() {
+ val behavior = DynamicDownloadDialogBehavior<View>(testContext, null, 10f)
+ val engineView: EngineView = mockk()
+ behavior.engineView = engineView
+ val validInputResultDetail: InputResultDetail = mockk()
+ every { engineView.getInputResultDetail() } returns validInputResultDetail
+
+ every { validInputResultDetail.canScrollToBottom() } returns true
+ every { validInputResultDetail.canScrollToTop() } returns false
+ assertTrue(behavior.shouldScroll)
+
+ every { validInputResultDetail.canScrollToBottom() } returns false
+ every { validInputResultDetail.canScrollToTop() } returns true
+ assertTrue(behavior.shouldScroll)
+
+ every { validInputResultDetail.canScrollToBottom() } returns true
+ every { validInputResultDetail.canScrollToTop() } returns true
+ assertTrue(behavior.shouldScroll)
+ }
+
+ @Test
+ fun `GIVEN a gesture that doesn't scroll the toolbar WHEN startNestedScroll THEN toolbar is expanded and nested scroll not accepted`() {
+ val behavior = spyk(DynamicDownloadDialogBehavior<View>(testContext, null, 10f))
+ val engineView: EngineView = mockk()
+ behavior.engineView = engineView
+ val inputResultDetail: InputResultDetail = mockk()
+ val animator: ValueAnimator = mockk(relaxed = true)
+ behavior.snapAnimator = animator
+ every { behavior.shouldScroll } returns false
+ every { behavior.forceExpand(any()) } just Runs
+ every { engineView.getInputResultDetail() } returns inputResultDetail
+ every { inputResultDetail.isTouchUnhandled() } returns true
+
+ val childView: View = mockk()
+ val acceptsNestedScroll = behavior.onStartNestedScroll(
+ coordinatorLayout = mockk(),
+ child = childView,
+ directTargetChild = mockk(),
+ target = mockk(),
+ axes = ViewCompat.SCROLL_AXIS_VERTICAL,
+ type = ViewCompat.TYPE_TOUCH,
+ )
+
+ verify { behavior.forceExpand(childView) }
+ verify { animator.cancel() }
+ assertFalse(acceptsNestedScroll)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/downloads/DynamicDownloadDialogTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/downloads/DynamicDownloadDialogTest.kt
new file mode 100644
index 0000000000..db1ecf7a29
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/downloads/DynamicDownloadDialogTest.kt
@@ -0,0 +1,37 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.downloads
+
+import android.webkit.MimeTypeMap
+import mozilla.components.browser.state.state.content.DownloadState
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.downloads.DynamicDownloadDialog.Companion.getCannotOpenFileErrorMessage
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+
+@RunWith(FenixRobolectricTestRunner::class)
+class DynamicDownloadDialogTest {
+
+ @Test
+ fun `WHEN calling getCannotOpenFileErrorMessage THEN should return the error message for the download file type`() {
+ val download = DownloadState(url = "", fileName = "image.gif")
+
+ shadowOf(MimeTypeMap.getSingleton()).apply {
+ addExtensionMimeTypeMapping(".gif", "image/gif")
+ }
+
+ val expected = testContext.getString(
+ R.string.mozac_feature_downloads_open_not_supported1,
+ "gif",
+ )
+
+ val result = getCannotOpenFileErrorMessage(testContext, download)
+ assertEquals(expected, result)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/downloads/FirstPartyDownloadDialogTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/downloads/FirstPartyDownloadDialogTest.kt
new file mode 100644
index 0000000000..5212281eb8
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/downloads/FirstPartyDownloadDialogTest.kt
@@ -0,0 +1,115 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.downloads
+
+import android.app.Activity
+import android.widget.FrameLayout
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.feature.downloads.toMegabyteOrKilobyteString
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.databinding.StartDownloadDialogLayoutBinding
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.robolectric.Robolectric
+
+@RunWith(FenixRobolectricTestRunner::class)
+class FirstPartyDownloadDialogTest {
+ private val activity: Activity = Robolectric.buildActivity(Activity::class.java).create().get()
+
+ @Before
+ fun setup() {
+ every { activity.settings().accessibilityServicesEnabled } returns false
+ }
+
+ @Test
+ fun `GIVEN the size of the download is known WHEN setting it's View THEN bind all provided download data and show the download size`() {
+ var wasPositiveActionDone = false
+ var wasNegativeActionDone = false
+ val contentSize = 5566L
+ val dialog = spyk(
+ FirstPartyDownloadDialog(
+ activity = activity,
+ filename = "Test",
+ contentSize = contentSize,
+ positiveButtonAction = { wasPositiveActionDone = true },
+ negativeButtonAction = { wasNegativeActionDone = true },
+ ),
+ )
+ every { dialog.dismiss() } just Runs
+ val dialogParent = FrameLayout(testContext)
+ dialog.container = dialogParent
+
+ dialog.setupView()
+
+ assertEquals(1, dialogParent.childCount)
+ assertEquals(R.id.dialogLayout, dialogParent.getChildAt(0).id)
+ val dialogBinding = dialog.binding as StartDownloadDialogLayoutBinding
+ assertEquals(
+ testContext.getString(
+ R.string.mozac_feature_downloads_dialog_title2,
+ contentSize.toMegabyteOrKilobyteString(),
+ ),
+ dialogBinding.title.text,
+ )
+ assertEquals("Test", dialogBinding.filename.text.toString())
+ assertFalse(wasPositiveActionDone)
+ assertFalse(wasNegativeActionDone)
+ dialogBinding.downloadButton.callOnClick()
+ verify { dialog.dismiss() }
+ assertTrue(wasPositiveActionDone)
+ dialogBinding.closeButton.callOnClick()
+ verify(exactly = 2) { dialog.dismiss() }
+ assertTrue(wasNegativeActionDone)
+ }
+
+ @Test
+ fun `GIVEN the size of the download is not known WHEN setting it's View THEN bind all provided download data and show the download size`() {
+ var wasPositiveActionDone = false
+ var wasNegativeActionDone = false
+ val contentSize = 0L
+ val dialog = spyk(
+ FirstPartyDownloadDialog(
+ activity = activity,
+ filename = "Test",
+ contentSize = contentSize,
+ positiveButtonAction = { wasPositiveActionDone = true },
+ negativeButtonAction = { wasNegativeActionDone = true },
+ ),
+ )
+ every { dialog.dismiss() } just Runs
+ val dialogParent = FrameLayout(testContext)
+ dialog.container = dialogParent
+
+ dialog.setupView()
+
+ assertEquals(1, dialogParent.childCount)
+ assertEquals(R.id.dialogLayout, dialogParent.getChildAt(0).id)
+ val dialogBinding = dialog.binding as StartDownloadDialogLayoutBinding
+ assertEquals(
+ testContext.getString(R.string.mozac_feature_downloads_dialog_download),
+ dialogBinding.title.text,
+ )
+ assertEquals("Test", dialogBinding.filename.text.toString())
+ assertFalse(wasPositiveActionDone)
+ assertFalse(wasNegativeActionDone)
+ dialogBinding.downloadButton.callOnClick()
+ verify { dialog.dismiss() }
+ assertTrue(wasPositiveActionDone)
+ dialogBinding.closeButton.callOnClick()
+ verify(exactly = 2) { dialog.dismiss() }
+ assertTrue(wasNegativeActionDone)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/downloads/StartDownloadDialogTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/downloads/StartDownloadDialogTest.kt
new file mode 100644
index 0000000000..00e3f7cefe
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/downloads/StartDownloadDialogTest.kt
@@ -0,0 +1,223 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.downloads
+
+import android.app.Activity
+import android.content.Context
+import android.view.Gravity
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.FrameLayout
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.core.view.children
+import androidx.core.view.isVisible
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.databinding.StartDownloadDialogLayoutBinding
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.utils.Settings
+import org.robolectric.Robolectric
+
+@RunWith(FenixRobolectricTestRunner::class)
+class StartDownloadDialogTest {
+ @Test
+ fun `WHEN the view is to be shown THEN set the scrim and other window customization bind the download values`() {
+ val activity = Robolectric.buildActivity(Activity::class.java).create().get()
+ val dialogParent = FrameLayout(testContext)
+ val dialogContainer = FrameLayout(testContext).also {
+ dialogParent.addView(it)
+ it.layoutParams = CoordinatorLayout.LayoutParams(0, 0)
+ }
+ val dialog = TestDownloadDialog(activity)
+
+ mockkStatic("mozilla.components.support.ktx.android.view.WindowKt", "org.mozilla.fenix.ext.ContextKt") {
+ every { any<Context>().settings() } returns mockk(relaxed = true)
+ every { any<Context>().components } returns mockk(relaxed = true)
+ val fluentDialog = dialog.show(dialogContainer)
+
+ val scrim = dialogParent.children.first { it.id == R.id.scrim }
+ assertTrue(scrim.hasOnClickListeners())
+ assertFalse(scrim.isSoundEffectsEnabled)
+ assertTrue(dialog.wasDownloadDataBinded)
+ assertEquals(
+ Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL,
+ (dialogContainer.layoutParams as CoordinatorLayout.LayoutParams).gravity,
+ )
+ assertEquals(
+ testContext.resources.getDimension(R.dimen.browser_fragment_download_dialog_elevation),
+ dialogContainer.elevation,
+ )
+ assertTrue(dialogContainer.isVisible)
+ assertEquals(dialog, fluentDialog)
+ }
+ }
+
+ @Test
+ fun `GIVEN a dismiss callback WHEN the dialog is dismissed THEN the callback is informed`() {
+ var wasDismissCalled = false
+ val activity = Robolectric.buildActivity(Activity::class.java).create().get()
+ val dialog = TestDownloadDialog(activity)
+ mockkStatic("org.mozilla.fenix.ext.ContextKt") {
+ every { any<Context>().components } returns mockk(relaxed = true)
+ val fluentDialog = dialog.onDismiss { wasDismissCalled = true }
+ dialog.onDismiss()
+
+ assertTrue(wasDismissCalled)
+ assertEquals(dialog, fluentDialog)
+ }
+ }
+
+ @Test
+ fun `GIVEN the download dialog is shown WHEN dismissed THEN remove the scrim, the dialog and any window customizations`() {
+ val activity = Robolectric.buildActivity(Activity::class.java).create().get()
+ val dialogParent = FrameLayout(testContext)
+ val dialogContainer = FrameLayout(testContext).also {
+ dialogParent.addView(it)
+ it.layoutParams = CoordinatorLayout.LayoutParams(0, 0)
+ }
+ val dialog = TestDownloadDialog(activity)
+ mockkStatic("mozilla.components.support.ktx.android.view.WindowKt", "org.mozilla.fenix.ext.ContextKt") {
+ every { any<Context>().settings() } returns mockk(relaxed = true)
+ every { any<Context>().components } returns mockk(relaxed = true)
+ dialog.show(dialogContainer)
+ dialog.binding = StartDownloadDialogLayoutBinding
+ .inflate(LayoutInflater.from(activity), dialogContainer, true)
+
+ dialog.dismiss()
+
+ assertNull(dialogParent.children.firstOrNull { it.id == R.id.scrim })
+ assertTrue(dialogParent.childCount == 1)
+ assertTrue(dialogContainer.childCount == 0)
+ assertFalse(dialogContainer.isVisible)
+ }
+ }
+
+ @Test
+ fun `GIVEN a ViewGroup WHEN enabling accessibility THEN enable it for all children but the dialog container`() {
+ val activity: Activity = mockk(relaxed = true)
+ val dialogParent = FrameLayout(testContext)
+ FrameLayout(testContext).also {
+ dialogParent.addView(it)
+ it.id = R.id.startDownloadDialogContainer
+ it.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
+ }
+ val otherView = View(testContext).also {
+ dialogParent.addView(it)
+ it.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
+ }
+ val dialog = TestDownloadDialog(activity)
+
+ dialog.enableSiblingsAccessibility(dialogParent)
+
+ assertEquals(listOf(otherView), dialogParent.children.filter { it.isImportantForAccessibility }.toList())
+ }
+
+ @Test
+ fun `GIVEN a ViewGroup WHEN disabling accessibility THEN disable it for all children but the dialog container`() {
+ val activity: Activity = mockk(relaxed = true)
+ val dialogParent = FrameLayout(testContext)
+ val dialogContainer = FrameLayout(testContext).also {
+ dialogParent.addView(it)
+ it.id = R.id.startDownloadDialogContainer
+ it.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
+ }
+ View(testContext).also {
+ dialogParent.addView(it)
+ it.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
+ }
+ val dialog = TestDownloadDialog(activity)
+
+ dialog.disableSiblingsAccessibility(dialogParent)
+
+ assertEquals(listOf(dialogContainer), dialogParent.children.filter { it.isImportantForAccessibility }.toList())
+ }
+
+ @Test
+ fun `GIVEN accessibility services are enabled WHEN the dialog is shown THEN disable siblings accessibility`() {
+ val activity = Robolectric.buildActivity(Activity::class.java).create().get()
+ val dialogParent = FrameLayout(testContext)
+ val dialogContainer = FrameLayout(testContext).also {
+ dialogParent.addView(it)
+ it.id = R.id.startDownloadDialogContainer
+ it.layoutParams = CoordinatorLayout.LayoutParams(0, 0)
+ it.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
+ }
+ View(testContext).also {
+ dialogParent.addView(it)
+ it.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
+ }
+
+ mockkStatic("org.mozilla.fenix.ext.ContextKt") {
+ val dialog = TestDownloadDialog(activity)
+
+ val settings: Settings = mockk {
+ every { accessibilityServicesEnabled } returns false
+ }
+ every { any<Context>().settings() } returns settings
+ every { any<Context>().components } returns mockk(relaxed = true)
+ dialog.show(dialogContainer)
+ assertEquals(2, dialogParent.children.count { it.isImportantForAccessibility })
+
+ every { settings.accessibilityServicesEnabled } returns true
+ dialog.show(dialogContainer)
+ assertEquals(listOf(dialogContainer), dialogParent.children.filter { it.isImportantForAccessibility }.toList())
+ }
+ }
+
+ @Test
+ fun `WHEN the dialog is dismissed THEN re-enable siblings accessibility`() {
+ val activity = Robolectric.buildActivity(Activity::class.java).create().get()
+ val dialogParent = FrameLayout(testContext)
+ val dialogContainer = FrameLayout(testContext).also {
+ dialogParent.addView(it)
+ it.id = R.id.startDownloadDialogContainer
+ it.layoutParams = CoordinatorLayout.LayoutParams(0, 0)
+ it.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
+ }
+ val accessibleView = View(testContext).also {
+ dialogParent.addView(it)
+ it.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
+ }
+ mockkStatic("org.mozilla.fenix.ext.ContextKt") {
+ val settings: Settings = mockk {
+ every { accessibilityServicesEnabled } returns true
+ }
+ every { any<Context>().settings() } returns settings
+ every { any<Context>().components } returns mockk(relaxed = true)
+ val dialog = TestDownloadDialog(activity)
+ dialog.show(dialogContainer)
+ dialog.binding = StartDownloadDialogLayoutBinding
+ .inflate(LayoutInflater.from(activity), dialogContainer, true)
+
+ dialog.dismiss()
+
+ assertEquals(
+ listOf(accessibleView),
+ dialogParent.children.filter { it.isVisible && it.isImportantForAccessibility }.toList(),
+ )
+ }
+ }
+}
+
+private class TestDownloadDialog(
+ activity: Activity,
+) : StartDownloadDialog(activity) {
+ var wasDownloadDataBinded = false
+
+ override fun setupView() {
+ wasDownloadDataBinded = true
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/downloads/ThirdPartyDownloadDialogTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/downloads/ThirdPartyDownloadDialogTest.kt
new file mode 100644
index 0000000000..0570203d2a
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/downloads/ThirdPartyDownloadDialogTest.kt
@@ -0,0 +1,54 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.downloads
+
+import android.app.Activity
+import android.widget.FrameLayout
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.feature.downloads.databinding.MozacDownloaderChooserPromptBinding
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.robolectric.Robolectric
+
+@RunWith(FenixRobolectricTestRunner::class)
+class ThirdPartyDownloadDialogTest {
+ private val activity: Activity = Robolectric.buildActivity(Activity::class.java).create().get()
+
+ @Test
+ fun `GIVEN a list of downloader apps WHEN setting it's View THEN bind all provided download data`() {
+ var wasNegativeActionDone = false
+ val dialog = spyk(
+ ThirdPartyDownloadDialog(
+ activity = activity,
+ downloaderApps = listOf(mockk(), mockk()),
+ onAppSelected = { /* cannot test the viewholder click */ },
+ negativeButtonAction = { wasNegativeActionDone = true },
+ ),
+ )
+ every { dialog.dismiss() } just Runs
+ val dialogParent = FrameLayout(testContext)
+ dialog.container = dialogParent
+
+ dialog.setupView()
+
+ assertEquals(1, dialogParent.childCount)
+ assertEquals(R.id.relativeLayout, dialogParent.getChildAt(0).id)
+ val dialogBinding = dialog.binding as MozacDownloaderChooserPromptBinding
+ assertEquals(2, dialogBinding.appsList.adapter?.itemCount)
+ dialogBinding.closeButton.callOnClick()
+ assertTrue(wasNegativeActionDone)
+ verify { dialog.dismiss() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/login/LoginExceptionFragmentStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/login/LoginExceptionFragmentStoreTest.kt
new file mode 100644
index 0000000000..38ff836f2d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/login/LoginExceptionFragmentStoreTest.kt
@@ -0,0 +1,30 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.exceptions.login
+
+import mozilla.components.feature.logins.exceptions.LoginException
+import mozilla.components.support.test.ext.joinBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotSame
+import org.junit.Test
+
+class LoginExceptionFragmentStoreTest {
+
+ @Test
+ fun onChange() {
+ val initialState = ExceptionsFragmentState()
+ val store = ExceptionsFragmentStore(initialState)
+ val newExceptionsItem: LoginException = object : LoginException {
+ override val id: Long
+ get() = 1234L
+ override val origin: String
+ get() = "test"
+ }
+
+ store.dispatch(ExceptionsFragmentAction.Change(listOf(newExceptionsItem))).joinBlocking()
+ assertNotSame(initialState, store.state)
+ assertEquals(listOf(newExceptionsItem), store.state.items)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/login/LoginExceptionsAdapterTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/login/LoginExceptionsAdapterTest.kt
new file mode 100644
index 0000000000..d0cd4d3472
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/login/LoginExceptionsAdapterTest.kt
@@ -0,0 +1,152 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.exceptions.login
+
+import android.content.Context
+import android.widget.FrameLayout
+import androidx.appcompat.view.ContextThemeWrapper
+import io.mockk.every
+import io.mockk.mockk
+import mozilla.components.browser.icons.BrowserIcons
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.exceptions.ExceptionsAdapter
+import org.mozilla.fenix.exceptions.viewholders.ExceptionsDeleteButtonViewHolder
+import org.mozilla.fenix.exceptions.viewholders.ExceptionsHeaderViewHolder
+import org.mozilla.fenix.exceptions.viewholders.ExceptionsListItemViewHolder
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class LoginExceptionsAdapterTest {
+
+ private lateinit var interactor: LoginExceptionsInteractor
+ private lateinit var adapter: LoginExceptionsAdapter
+ private lateinit var context: Context
+
+ @Before
+ fun setup() {
+ interactor = mockk()
+ adapter = LoginExceptionsAdapter(interactor)
+ context = ContextThemeWrapper(testContext, R.style.NormalTheme)
+ }
+
+ @Test
+ fun `creates correct view holder type`() {
+ every { testContext.components.core.icons } returns BrowserIcons(testContext, mockk(relaxed = true))
+ val parent = FrameLayout(context)
+ adapter.updateData(listOf(mockk(), mockk()))
+ assertEquals(4, adapter.itemCount)
+
+ val holders = (0 until adapter.itemCount).asSequence()
+ .map { i -> adapter.getItemViewType(i) }
+ .map { viewType -> adapter.onCreateViewHolder(parent, viewType) }
+ .toList()
+ assertEquals(4, holders.size)
+
+ assertTrue(holders[0] is ExceptionsHeaderViewHolder)
+ assertTrue(holders[1] is ExceptionsListItemViewHolder<*>)
+ assertTrue(holders[2] is ExceptionsListItemViewHolder<*>)
+ assertTrue(holders[3] is ExceptionsDeleteButtonViewHolder)
+ }
+
+ @Test
+ fun `headers and delete should check if the other object is the same`() {
+ assertTrue(
+ LoginExceptionsAdapter.DiffCallback.areItemsTheSame(
+ ExceptionsAdapter.AdapterItem.Header,
+ ExceptionsAdapter.AdapterItem.Header,
+ ),
+ )
+ assertTrue(
+ LoginExceptionsAdapter.DiffCallback.areItemsTheSame(
+ ExceptionsAdapter.AdapterItem.DeleteButton,
+ ExceptionsAdapter.AdapterItem.DeleteButton,
+ ),
+ )
+ assertFalse(
+ LoginExceptionsAdapter.DiffCallback.areItemsTheSame(
+ ExceptionsAdapter.AdapterItem.Header,
+ ExceptionsAdapter.AdapterItem.DeleteButton,
+ ),
+ )
+ assertTrue(
+ LoginExceptionsAdapter.DiffCallback.areContentsTheSame(
+ ExceptionsAdapter.AdapterItem.Header,
+ ExceptionsAdapter.AdapterItem.Header,
+ ),
+ )
+ assertTrue(
+ LoginExceptionsAdapter.DiffCallback.areContentsTheSame(
+ ExceptionsAdapter.AdapterItem.DeleteButton,
+ ExceptionsAdapter.AdapterItem.DeleteButton,
+ ),
+ )
+ assertFalse(
+ LoginExceptionsAdapter.DiffCallback.areContentsTheSame(
+ ExceptionsAdapter.AdapterItem.DeleteButton,
+ ExceptionsAdapter.AdapterItem.Header,
+ ),
+ )
+ }
+
+ @Test
+ fun `items with the same id should be marked as same`() {
+ assertTrue(
+ LoginExceptionsAdapter.DiffCallback.areItemsTheSame(
+ LoginExceptionsAdapter.LoginAdapterItem(
+ mockk {
+ every { id } returns 12L
+ },
+ ),
+ LoginExceptionsAdapter.LoginAdapterItem(
+ mockk {
+ every { id } returns 12L
+ },
+ ),
+ ),
+ )
+ assertFalse(
+ LoginExceptionsAdapter.DiffCallback.areItemsTheSame(
+ LoginExceptionsAdapter.LoginAdapterItem(
+ mockk {
+ every { id } returns 14L
+ },
+ ),
+ LoginExceptionsAdapter.LoginAdapterItem(
+ mockk {
+ every { id } returns 12L
+ },
+ ),
+ ),
+ )
+ assertFalse(
+ LoginExceptionsAdapter.DiffCallback.areItemsTheSame(
+ LoginExceptionsAdapter.LoginAdapterItem(
+ mockk {
+ every { id } returns 14L
+ },
+ ),
+ ExceptionsAdapter.AdapterItem.Header,
+ ),
+ )
+ assertFalse(
+ LoginExceptionsAdapter.DiffCallback.areItemsTheSame(
+ ExceptionsAdapter.AdapterItem.DeleteButton,
+ LoginExceptionsAdapter.LoginAdapterItem(
+ mockk {
+ every { id } returns 14L
+ },
+ ),
+ ),
+ )
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/login/LoginExceptionsInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/login/LoginExceptionsInteractorTest.kt
new file mode 100644
index 0000000000..1a0da3db2f
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/login/LoginExceptionsInteractorTest.kt
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.exceptions.login
+
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import mozilla.components.feature.logins.exceptions.LoginException
+import mozilla.components.feature.logins.exceptions.LoginExceptionStorage
+import org.junit.Before
+import org.junit.Test
+
+class LoginExceptionsInteractorTest {
+
+ private lateinit var loginExceptionStorage: LoginExceptionStorage
+ private lateinit var interactor: LoginExceptionsInteractor
+ private val scope = TestScope(UnconfinedTestDispatcher())
+
+ @Before
+ fun setup() {
+ loginExceptionStorage = mockk(relaxed = true)
+ interactor = DefaultLoginExceptionsInteractor(scope, loginExceptionStorage)
+ }
+
+ @Test
+ fun onDeleteAll() = scope.runTest {
+ interactor.onDeleteAll()
+ verify { loginExceptionStorage.deleteAllLoginExceptions() }
+ }
+
+ @Test
+ fun onDeleteOne() = scope.runTest {
+ val exceptionsItem: LoginException = mockk()
+ interactor.onDeleteOne(exceptionsItem)
+ verify { loginExceptionStorage.removeLoginException(exceptionsItem) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/login/LoginExceptionsViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/login/LoginExceptionsViewTest.kt
new file mode 100644
index 0000000000..5bc8d5076f
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/login/LoginExceptionsViewTest.kt
@@ -0,0 +1,63 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.exceptions.login
+
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.core.view.isVisible
+import androidx.recyclerview.widget.LinearLayoutManager
+import io.mockk.mockk
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class LoginExceptionsViewTest {
+
+ private lateinit var parent: ViewGroup
+ private lateinit var interactor: LoginExceptionsInteractor
+ private lateinit var view: LoginExceptionsView
+
+ @Before
+ fun setup() {
+ parent = FrameLayout(testContext)
+ interactor = mockk()
+ view = LoginExceptionsView(
+ parent,
+ interactor,
+ )
+ }
+
+ @Test
+ fun `sets empty message text`() {
+ assertEquals(
+ "Firefox Fenix won’t save passwords for sites listed here.",
+ view.binding.exceptionsEmptyMessage.text,
+ )
+ assertTrue(view.binding.exceptionsList.adapter is LoginExceptionsAdapter)
+ assertTrue(view.binding.exceptionsList.layoutManager is LinearLayoutManager)
+ }
+
+ @Test
+ fun `hide list when there are no items`() {
+ view.update(emptyList())
+
+ assertTrue(view.binding.exceptionsEmptyView.isVisible)
+ assertFalse(view.binding.exceptionsList.isVisible)
+ }
+
+ @Test
+ fun `shows list when there are items`() {
+ view.update(listOf(mockk()))
+
+ assertFalse(view.binding.exceptionsEmptyView.isVisible)
+ assertTrue(view.binding.exceptionsList.isVisible)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsAdapterTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsAdapterTest.kt
new file mode 100644
index 0000000000..b233dfa018
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsAdapterTest.kt
@@ -0,0 +1,152 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.exceptions.trackingprotection
+
+import android.content.Context
+import android.widget.FrameLayout
+import androidx.appcompat.view.ContextThemeWrapper
+import io.mockk.every
+import io.mockk.mockk
+import mozilla.components.browser.icons.BrowserIcons
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.exceptions.ExceptionsAdapter
+import org.mozilla.fenix.exceptions.viewholders.ExceptionsDeleteButtonViewHolder
+import org.mozilla.fenix.exceptions.viewholders.ExceptionsHeaderViewHolder
+import org.mozilla.fenix.exceptions.viewholders.ExceptionsListItemViewHolder
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class TrackingProtectionExceptionsAdapterTest {
+
+ private lateinit var interactor: TrackingProtectionExceptionsInteractor
+ private lateinit var adapter: TrackingProtectionExceptionsAdapter
+ private lateinit var context: Context
+
+ @Before
+ fun setup() {
+ interactor = mockk()
+ adapter = TrackingProtectionExceptionsAdapter(interactor)
+ context = ContextThemeWrapper(testContext, R.style.NormalTheme)
+ }
+
+ @Test
+ fun `creates correct view holder type`() {
+ every { testContext.components.core.icons } returns BrowserIcons(testContext, mockk(relaxed = true))
+ val parent = FrameLayout(context)
+ adapter.updateData(listOf(mockk(), mockk()))
+ assertEquals(4, adapter.itemCount)
+
+ val holders = (0 until adapter.itemCount).asSequence()
+ .map { i -> adapter.getItemViewType(i) }
+ .map { viewType -> adapter.onCreateViewHolder(parent, viewType) }
+ .toList()
+ assertEquals(4, holders.size)
+
+ assertTrue(holders[0] is ExceptionsHeaderViewHolder)
+ assertTrue(holders[1] is ExceptionsListItemViewHolder<*>)
+ assertTrue(holders[2] is ExceptionsListItemViewHolder<*>)
+ assertTrue(holders[3] is ExceptionsDeleteButtonViewHolder)
+ }
+
+ @Test
+ fun `headers and delete should check if the other object is the same`() {
+ assertTrue(
+ TrackingProtectionExceptionsAdapter.DiffCallback.areItemsTheSame(
+ ExceptionsAdapter.AdapterItem.Header,
+ ExceptionsAdapter.AdapterItem.Header,
+ ),
+ )
+ assertTrue(
+ TrackingProtectionExceptionsAdapter.DiffCallback.areItemsTheSame(
+ ExceptionsAdapter.AdapterItem.DeleteButton,
+ ExceptionsAdapter.AdapterItem.DeleteButton,
+ ),
+ )
+ assertFalse(
+ TrackingProtectionExceptionsAdapter.DiffCallback.areItemsTheSame(
+ ExceptionsAdapter.AdapterItem.Header,
+ ExceptionsAdapter.AdapterItem.DeleteButton,
+ ),
+ )
+ assertTrue(
+ TrackingProtectionExceptionsAdapter.DiffCallback.areContentsTheSame(
+ ExceptionsAdapter.AdapterItem.Header,
+ ExceptionsAdapter.AdapterItem.Header,
+ ),
+ )
+ assertTrue(
+ TrackingProtectionExceptionsAdapter.DiffCallback.areContentsTheSame(
+ ExceptionsAdapter.AdapterItem.DeleteButton,
+ ExceptionsAdapter.AdapterItem.DeleteButton,
+ ),
+ )
+ assertFalse(
+ TrackingProtectionExceptionsAdapter.DiffCallback.areContentsTheSame(
+ ExceptionsAdapter.AdapterItem.DeleteButton,
+ ExceptionsAdapter.AdapterItem.Header,
+ ),
+ )
+ }
+
+ @Test
+ fun `items with the same url should be marked as same`() {
+ assertTrue(
+ TrackingProtectionExceptionsAdapter.DiffCallback.areItemsTheSame(
+ TrackingProtectionExceptionsAdapter.TrackingProtectionAdapterItem(
+ mockk {
+ every { url } returns "https://mozilla.org"
+ },
+ ),
+ TrackingProtectionExceptionsAdapter.TrackingProtectionAdapterItem(
+ mockk {
+ every { url } returns "https://mozilla.org"
+ },
+ ),
+ ),
+ )
+ assertFalse(
+ TrackingProtectionExceptionsAdapter.DiffCallback.areItemsTheSame(
+ TrackingProtectionExceptionsAdapter.TrackingProtectionAdapterItem(
+ mockk {
+ every { url } returns "https://mozilla.org"
+ },
+ ),
+ TrackingProtectionExceptionsAdapter.TrackingProtectionAdapterItem(
+ mockk {
+ every { url } returns "https://firefox.com"
+ },
+ ),
+ ),
+ )
+ assertFalse(
+ TrackingProtectionExceptionsAdapter.DiffCallback.areItemsTheSame(
+ TrackingProtectionExceptionsAdapter.TrackingProtectionAdapterItem(
+ mockk {
+ every { url } returns "https://mozilla.org"
+ },
+ ),
+ ExceptionsAdapter.AdapterItem.Header,
+ ),
+ )
+ assertFalse(
+ TrackingProtectionExceptionsAdapter.DiffCallback.areItemsTheSame(
+ ExceptionsAdapter.AdapterItem.DeleteButton,
+ TrackingProtectionExceptionsAdapter.TrackingProtectionAdapterItem(
+ mockk {
+ every { url } returns "https://mozilla.org"
+ },
+ ),
+ ),
+ )
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsFragmentStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsFragmentStoreTest.kt
new file mode 100644
index 0000000000..41702b525b
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsFragmentStoreTest.kt
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.exceptions.trackingprotection
+
+import kotlinx.coroutines.test.runTest
+import mozilla.components.concept.engine.content.blocking.TrackingProtectionException
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotSame
+import org.junit.Test
+
+class TrackingProtectionExceptionsFragmentStoreTest {
+ @Test
+ fun onChange() = runTest {
+ val initialState = ExceptionsFragmentState()
+ val store = ExceptionsFragmentStore(initialState)
+ val newExceptionsItem = ExceptionItem("URL")
+
+ store.dispatch(ExceptionsFragmentAction.Change(listOf(newExceptionsItem))).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(
+ store.state.items,
+ listOf(newExceptionsItem),
+ )
+ }
+
+ private data class ExceptionItem(override val url: String) : TrackingProtectionException
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsInteractorTest.kt
new file mode 100644
index 0000000000..901b23aa79
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsInteractorTest.kt
@@ -0,0 +1,145 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.exceptions.trackingprotection
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.browser.state.selector.findTab
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.TrackingProtectionState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.Engine
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.concept.engine.content.blocking.TrackingProtectionException
+import mozilla.components.concept.engine.content.blocking.TrackingProtectionExceptionStorage
+import mozilla.components.feature.session.TrackingProtectionUseCases
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.middleware.CaptureActionsMiddleware
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.BrowserDirection
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.SupportUtils
+
+@RunWith(FenixRobolectricTestRunner::class)
+class TrackingProtectionExceptionsInteractorTest {
+
+ private lateinit var interactor: TrackingProtectionExceptionsInteractor
+
+ private val activity: HomeActivity = mockk(relaxed = true)
+ private val engine: Engine = mockk(relaxed = true)
+
+ private val results: List<TrackingProtectionException> = emptyList()
+ private val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ // Using copy to add TP state because `createTab` doesn't support it right now.
+ createTab("https://mozilla.org", false, "tab1")
+ .copy(trackingProtection = TrackingProtectionState(ignoredOnTrackingProtection = true)),
+ createTab("https://firefox.com", false, "tab2")
+ .copy(trackingProtection = TrackingProtectionState(ignoredOnTrackingProtection = true)),
+ ),
+ ),
+ )
+ private val capture =
+ CaptureActionsMiddleware<ExceptionsFragmentState, ExceptionsFragmentAction>()
+ private val exceptionsStore = ExceptionsFragmentStore(middlewares = listOf(capture))
+ private val trackingProtectionUseCases = TrackingProtectionUseCases(store, engine)
+ private val trackingStorage: TrackingProtectionExceptionStorage =
+ object : TrackingProtectionExceptionStorage {
+ override fun fetchAll(onResult: (List<TrackingProtectionException>) -> Unit) {
+ fetchedAll = true
+ onResult(results)
+ }
+
+ override fun removeAll(activeSessions: List<EngineSession>?, onRemove: () -> Unit) {
+ removedAll = true
+ }
+
+ override fun add(session: EngineSession, persistInPrivateMode: Boolean) = Unit
+ override fun contains(session: EngineSession, onResult: (Boolean) -> Unit) = Unit
+ override fun remove(session: EngineSession) = Unit
+ override fun remove(exception: TrackingProtectionException) = Unit
+ }
+ private val exceptionsItem: (String) -> TrackingProtectionException = {
+ object : TrackingProtectionException {
+ override val url = it
+ }
+ }
+ private var fetchedAll: Boolean = false
+ private var removedAll: Boolean = false
+
+ @Before
+ fun setup() {
+ interactor = DefaultTrackingProtectionExceptionsInteractor(
+ activity = activity,
+ exceptionsStore = exceptionsStore,
+ trackingProtectionUseCases = trackingProtectionUseCases,
+ )
+
+ every { engine.trackingProtectionExceptionStore } returns trackingStorage
+
+ // Re-setting boolean checks in case they are not re-initialized per test run.
+ fetchedAll = false
+ removedAll = false
+ }
+
+ @Test
+ fun onLearnMore() {
+ interactor.onLearnMore()
+
+ val supportUrl = SupportUtils.getGenericSumoURLForTopic(
+ SupportUtils.SumoTopic.TRACKING_PROTECTION,
+ )
+ verify {
+ activity.openToBrowserAndLoad(
+ searchTermOrURL = supportUrl,
+ newTab = true,
+ from = BrowserDirection.FromTrackingProtectionExceptions,
+ )
+ }
+ }
+
+ @Test
+ fun onDeleteAll() {
+ interactor.onDeleteAll()
+
+ assertTrue(removedAll)
+ assertTrue(fetchedAll)
+
+ exceptionsStore.waitUntilIdle()
+
+ capture.assertLastAction(ExceptionsFragmentAction.Change::class) {
+ assertEquals(results, it.list)
+ }
+ }
+
+ @Test
+ fun onDeleteOne() {
+ interactor.onDeleteOne(exceptionsItem.invoke("https://mozilla.org"))
+
+ assertTrue(fetchedAll)
+
+ exceptionsStore.waitUntilIdle()
+ store.waitUntilIdle()
+
+ capture.assertLastAction(ExceptionsFragmentAction.Change::class) {
+ assertEquals(results, it.list)
+ }
+
+ val tab = store.state.findTab("tab1")!!
+ assertFalse(tab.trackingProtection.ignoredOnTrackingProtection)
+
+ val tab2 = store.state.findTab("tab2")!!
+ assertTrue(tab2.trackingProtection.ignoredOnTrackingProtection)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsViewTest.kt
new file mode 100644
index 0000000000..5ae923e024
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsViewTest.kt
@@ -0,0 +1,89 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.exceptions.trackingprotection
+
+import android.text.Spannable
+import android.text.method.LinkMovementMethod
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.core.view.isVisible
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkConstructor
+import io.mockk.unmockkConstructor
+import io.mockk.verify
+import mozilla.components.concept.engine.content.blocking.TrackingProtectionException
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.databinding.ComponentExceptionsBinding
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class TrackingProtectionExceptionsViewTest {
+
+ private lateinit var container: ViewGroup
+ private lateinit var interactor: TrackingProtectionExceptionsInteractor
+ private lateinit var exceptionsView: TrackingProtectionExceptionsView
+ private lateinit var binding: ComponentExceptionsBinding
+
+ @Before
+ fun setup() {
+ mockkConstructor(TrackingProtectionExceptionsAdapter::class)
+ every { anyConstructed<TrackingProtectionExceptionsAdapter>().updateData(any()) } just Runs
+
+ container = FrameLayout(testContext)
+ interactor = mockk()
+
+ exceptionsView = TrackingProtectionExceptionsView(
+ container,
+ interactor,
+ )
+ binding = ComponentExceptionsBinding.bind(container)
+ }
+
+ @After
+ fun teardown() {
+ unmockkConstructor(TrackingProtectionExceptionsAdapter::class)
+ }
+
+ @Test
+ fun `binds exception text`() {
+ assertTrue(binding.exceptionsLearnMore.movementMethod is LinkMovementMethod)
+ assertTrue(binding.exceptionsLearnMore.text is Spannable)
+ assertEquals("Learn more", binding.exceptionsLearnMore.text.toString())
+
+ every { interactor.onLearnMore() } just Runs
+ binding.exceptionsLearnMore.performClick()
+ verify { interactor.onLearnMore() }
+ }
+
+ @Test
+ fun `binds empty list to adapter`() {
+ exceptionsView.update(emptyList())
+
+ assertTrue(binding.exceptionsEmptyView.isVisible)
+ assertFalse(binding.exceptionsList.isVisible)
+
+ verify { anyConstructed<TrackingProtectionExceptionsAdapter>().updateData(emptyList()) }
+ }
+
+ @Test
+ fun `binds list with items to adapter`() {
+ val items = listOf<TrackingProtectionException>(mockk(), mockk())
+ exceptionsView.update(items)
+
+ assertFalse(binding.exceptionsEmptyView.isVisible)
+ assertTrue(binding.exceptionsList.isVisible)
+ verify { anyConstructed<TrackingProtectionExceptionsAdapter>().updateData(items) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/viewholders/ExceptionsDeleteButtonViewHolderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/viewholders/ExceptionsDeleteButtonViewHolderTest.kt
new file mode 100644
index 0000000000..a0c7304486
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/viewholders/ExceptionsDeleteButtonViewHolderTest.kt
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.exceptions.viewholders
+
+import android.view.View
+import com.google.android.material.button.MaterialButton
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.R
+import org.mozilla.fenix.exceptions.ExceptionsInteractor
+
+class ExceptionsDeleteButtonViewHolderTest {
+
+ @MockK private lateinit var view: View
+
+ @MockK private lateinit var deleteButton: MaterialButton
+
+ @MockK private lateinit var interactor: ExceptionsInteractor<Unit>
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ every { view.findViewById<MaterialButton>(R.id.removeAllExceptions) } returns deleteButton
+ }
+
+ @Test
+ fun `delete button calls interactor`() {
+ val slot = slot<View.OnClickListener>()
+ every { deleteButton.setOnClickListener(capture(slot)) } just Runs
+ ExceptionsDeleteButtonViewHolder(view, interactor)
+
+ every { interactor.onDeleteAll() } just Runs
+ slot.captured.onClick(mockk())
+ verify { interactor.onDeleteAll() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/viewholders/ExceptionsHeaderViewHolderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/viewholders/ExceptionsHeaderViewHolderTest.kt
new file mode 100644
index 0000000000..1a3bd7b740
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/viewholders/ExceptionsHeaderViewHolderTest.kt
@@ -0,0 +1,40 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.exceptions.viewholders
+
+import android.view.View
+import android.widget.TextView
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.R
+
+class ExceptionsHeaderViewHolderTest {
+
+ private lateinit var view: View
+ private lateinit var description: TextView
+
+ @Before
+ fun setup() {
+ description = mockk(relaxUnitFun = true)
+ view = mockk {
+ every { findViewById<TextView>(R.id.exceptions_description) } returns description
+ every {
+ context.getString(eq(R.string.preferences_passwords_exceptions_description_2), any())
+ } returns "Firefox fenix won’t save passwords for these sites."
+ every {
+ context.getString(R.string.app_name)
+ } returns "Firefox fenix"
+ }
+ }
+
+ @Test
+ fun `sets description text`() {
+ ExceptionsHeaderViewHolder(view, R.string.preferences_passwords_exceptions_description_2)
+ verify { description.text = "Firefox fenix won’t save passwords for these sites." }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/viewholders/ExceptionsListItemViewHolderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/viewholders/ExceptionsListItemViewHolderTest.kt
new file mode 100644
index 0000000000..53404bbe66
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/exceptions/viewholders/ExceptionsListItemViewHolderTest.kt
@@ -0,0 +1,76 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.exceptions.viewholders
+
+import android.view.View
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.browser.icons.BrowserIcons
+import mozilla.components.browser.icons.IconRequest
+import mozilla.components.ui.widgets.WidgetSiteItemView
+import org.junit.Assert.assertNotNull
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.exceptions.ExceptionsInteractor
+import org.mozilla.fenix.helpers.MockkRetryTestRule
+
+class ExceptionsListItemViewHolderTest {
+
+ @MockK(relaxed = true)
+ private lateinit var view: WidgetSiteItemView
+
+ @MockK private lateinit var icons: BrowserIcons
+
+ @MockK private lateinit var interactor: ExceptionsInteractor<Exception>
+
+ @get:Rule
+ val mockkRule = MockkRetryTestRule()
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+
+ every { icons.loadIntoView(view.iconView, any()) } returns mockk()
+ }
+
+ @Test
+ fun `sets url text and loads favicon - mozilla`() {
+ ExceptionsListItemViewHolder(view, interactor, icons)
+ .bind(Exception(), url = "mozilla.org")
+ verify { view.setText(label = "mozilla.org", caption = null) }
+ verify { icons.loadIntoView(view.iconView, IconRequest("mozilla.org")) }
+ }
+
+ @Test
+ fun `sets url text and loads favicon - example`() {
+ ExceptionsListItemViewHolder(view, interactor, icons)
+ .bind(Exception(), url = "https://example.com/icon.svg")
+ verify { view.setText(label = "https://example.com/icon.svg", caption = null) }
+ verify { icons.loadIntoView(view.iconView, IconRequest("https://example.com/icon.svg")) }
+ }
+
+ @Test
+ fun `delete button calls interactor`() {
+ var clickListener: ((View) -> Unit)? = null
+ val exception = Exception()
+ every { view.setSecondaryButton(any(), any<Int>(), any()) } answers {
+ clickListener = thirdArg()
+ }
+ ExceptionsListItemViewHolder(view, interactor, icons).bind(exception, url = "mozilla.org")
+
+ every { interactor.onDeleteOne(exception) } just Runs
+ assertNotNull(clickListener)
+ clickListener!!(mockk())
+ verify { interactor.onDeleteOne(exception) }
+ }
+
+ class Exception
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/experiments/NimbusSetupKtTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/experiments/NimbusSetupKtTest.kt
new file mode 100644
index 0000000000..09a78bca5d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/experiments/NimbusSetupKtTest.kt
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.experiments
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.mozilla.experiments.nimbus.internal.NimbusException
+
+class NimbusSetupKtTest {
+ @Test
+ fun `WHEN error is reportable THEN return true`() {
+ val error = NimbusException.IoException("bad error")
+
+ assertTrue(error.isReportableError())
+ }
+
+ @Test
+ fun `WHEN error is non-reportable THEN return false`() {
+ val error = NimbusException.ClientException("oops")
+
+ assertFalse(error.isReportableError())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ActivityTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ActivityTest.kt
new file mode 100644
index 0000000000..75621e3910
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ActivityTest.kt
@@ -0,0 +1,79 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.ext
+
+import android.app.Activity
+import android.view.View
+import android.view.WindowManager
+import io.mockk.mockk
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.BrowserDirection
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.robolectric.Robolectric
+import org.robolectric.Shadows.shadowOf
+
+@RunWith(FenixRobolectricTestRunner::class)
+class ActivityTest {
+
+ // This will be addressed on https://github.com/mozilla-mobile/fenix/issues/17804
+ @Suppress("DEPRECATION")
+ @Test
+ fun testEnterImmersiveMode() {
+ val activity = Robolectric.buildActivity(Activity::class.java).create().get()
+ val window = activity.window
+
+ // Turn off Keep Screen on Flag if it is on
+ if (shadowOf(window).getFlag(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)) window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+
+ // Make sure that System UI flags are not set before the test
+ val flags = arrayOf(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION, View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN, View.SYSTEM_UI_FLAG_HIDE_NAVIGATION, View.SYSTEM_UI_FLAG_FULLSCREEN, View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY)
+ if (flags.any { f -> (window.decorView.systemUiVisibility and f) == f }) {
+ window.decorView.systemUiVisibility = 0
+ }
+
+ // Run
+ activity.enterToImmersiveMode()
+
+ // Test
+ for (f in flags) assertEquals(f, window.decorView.systemUiVisibility and f)
+ assertTrue(shadowOf(window).getFlag(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON))
+ }
+
+ @Test
+ fun `WHEN Activity is not supported THEN getNavDirections throws IllegalArgument exception`() {
+ val activity = mockk<Activity>()
+
+ assertThrows(IllegalArgumentException::class.java) {
+ activity.getNavDirections(
+ BrowserDirection.FromGlobal,
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN Activity is not supported THEN getIntentSource throws IllegalArgument exception`() {
+ val activity = mockk<Activity>()
+
+ assertThrows(IllegalArgumentException::class.java) { activity.getIntentSource(mockk()) }
+ }
+
+ @Test
+ fun `WHEN Activity is not supported THEN getIntentSessionId throws IllegalArgument exception`() {
+ val activity = mockk<Activity>()
+
+ assertThrows(IllegalArgumentException::class.java) { activity.getIntentSessionId(mockk()) }
+ }
+
+ @Test
+ fun `WHEN Activity is not supported THEN getBreadcrumbMessage throws IllegalArgument exception`() {
+ val activity = mockk<Activity>()
+
+ assertThrows(IllegalArgumentException::class.java) { activity.getBreadcrumbMessage(mockk()) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/AppStateTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/AppStateTest.kt
new file mode 100644
index 0000000000..c568d01072
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/AppStateTest.kt
@@ -0,0 +1,609 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.ext
+
+import io.mockk.every
+import io.mockk.mockk
+import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory
+import mozilla.components.service.pocket.PocketStory.PocketSponsoredStory
+import mozilla.components.service.pocket.PocketStory.PocketSponsoredStoryCaps
+import mozilla.components.service.pocket.PocketStory.PocketSponsoredStoryShim
+import org.junit.Assert
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.mozilla.fenix.components.appstate.AppState
+import org.mozilla.fenix.home.pocket.POCKET_STORIES_DEFAULT_CATEGORY_NAME
+import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory
+import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesSelectedCategory
+import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTabState
+import org.mozilla.fenix.utils.Settings
+import java.util.concurrent.TimeUnit
+import kotlin.random.Random
+
+class AppStateTest {
+ private val otherStoriesCategory =
+ PocketRecommendedStoriesCategory("other", getFakePocketStories(3, "other"))
+ private val anotherStoriesCategory =
+ PocketRecommendedStoriesCategory("another", getFakePocketStories(3, "another"))
+ private val defaultStoriesCategory = PocketRecommendedStoriesCategory(
+ POCKET_STORIES_DEFAULT_CATEGORY_NAME,
+ getFakePocketStories(3),
+ )
+
+ @Test
+ fun `GIVEN no category is selected and no sponsored stories are available WHEN getFilteredStories is called THEN only Pocket stories from the default category are returned`() {
+ val state = AppState(
+ pocketStoriesCategories = listOf(
+ otherStoriesCategory,
+ anotherStoriesCategory,
+ defaultStoriesCategory,
+ ),
+ )
+
+ val result = state.getFilteredStories()
+
+ assertNull(
+ result.firstOrNull {
+ it is PocketRecommendedStory && it.category != POCKET_STORIES_DEFAULT_CATEGORY_NAME
+ },
+ )
+ }
+
+ @Test
+ fun `GIVEN no category is selected and no sponsored stories are available WHEN getFilteredStories is called THEN no more than the default stories number are returned from the default category`() {
+ val defaultStoriesCategoryWithManyStories = PocketRecommendedStoriesCategory(
+ POCKET_STORIES_DEFAULT_CATEGORY_NAME,
+ getFakePocketStories(POCKET_STORIES_TO_SHOW_COUNT + 2),
+ )
+ val state = AppState(
+ pocketStoriesCategories = listOf(
+ otherStoriesCategory,
+ anotherStoriesCategory,
+ defaultStoriesCategoryWithManyStories,
+ ),
+ )
+
+ val result = state.getFilteredStories()
+
+ assertEquals(POCKET_STORIES_TO_SHOW_COUNT, result.size)
+ }
+
+ @Test
+ fun `GIVEN no category is selected and 1 sponsored story available WHEN getFilteredStories is called THEN get stories from the default category combined with the sponsored one`() {
+ val defaultStoriesCategoryWithManyStories = PocketRecommendedStoriesCategory(
+ POCKET_STORIES_DEFAULT_CATEGORY_NAME,
+ getFakePocketStories(POCKET_STORIES_TO_SHOW_COUNT),
+ )
+ val sponsoredStories = getFakeSponsoredStories(1)
+ val state = AppState(
+ pocketStoriesCategories = listOf(
+ otherStoriesCategory,
+ anotherStoriesCategory,
+ defaultStoriesCategoryWithManyStories,
+ ),
+ pocketSponsoredStories = sponsoredStories,
+ )
+
+ val result = state.getFilteredStories().toMutableList()
+
+ assertEquals(POCKET_STORIES_TO_SHOW_COUNT, result.size)
+ assertEquals(sponsoredStories[0], result[1]) // second story should be a sponsored one
+ result.removeAt(1) // remove the sponsored story to hopefully only remain with general recommendations
+ assertNull(
+ result.firstOrNull {
+ it is PocketRecommendedStory && it.category != POCKET_STORIES_DEFAULT_CATEGORY_NAME
+ },
+ )
+ }
+
+ @Test
+ fun `GIVEN no category is selected and 2 sponsored stories available WHEN getFilteredStories is called THEN get stories from the default category combined with the sponsored stories`() {
+ val defaultStoriesCategoryWithManyStories = PocketRecommendedStoriesCategory(
+ POCKET_STORIES_DEFAULT_CATEGORY_NAME,
+ getFakePocketStories(POCKET_STORIES_TO_SHOW_COUNT),
+ )
+ val sponsoredStories = getFakeSponsoredStories(4)
+ val state = AppState(
+ pocketStoriesCategories = listOf(
+ otherStoriesCategory,
+ anotherStoriesCategory,
+ defaultStoriesCategoryWithManyStories,
+ ),
+ pocketSponsoredStories = sponsoredStories,
+ )
+
+ val result = state.getFilteredStories().toMutableList()
+
+ assertEquals(POCKET_STORIES_TO_SHOW_COUNT, result.size)
+ // second story should be a sponsored one
+ assertEquals(sponsoredStories[1], result[1])
+ assertEquals(sponsoredStories[3], result[POCKET_STORIES_TO_SHOW_COUNT - 1])
+ // remove the sponsored stories to hopefully only remain with general recommendations
+ result.removeAt(7)
+ result.removeAt(1)
+ assertNull(
+ result.firstOrNull {
+ it is PocketRecommendedStory && it.category != POCKET_STORIES_DEFAULT_CATEGORY_NAME
+ },
+ )
+ }
+
+ @Test
+ fun `GIVEN a list of sponsored stories WHEN filtering them THEN have them ordered by priority`() {
+ val stories = getFakeSponsoredStories(4).mapIndexed { index, story ->
+ story.copy(priority = index)
+ }
+
+ val result = getFilteredSponsoredStories(stories, 10)
+
+ assertEquals(4, result.size)
+ assertEquals(stories.reversed(), result)
+ }
+
+ @Test
+ fun `GIVEN a list of sponsored stories WHEN filtering them THEN drop the ones already shown for the maximum number of times in lifetime`() {
+ val stories = getFakeSponsoredStories(4).mapIndexed { index, story ->
+ when (index % 2 == 0) {
+ true -> story.copy(
+ caps = story.caps.copy(
+ currentImpressions = listOf(1, 2, 3),
+ lifetimeCount = 3,
+ ),
+ )
+ false -> story
+ }
+ }
+
+ val result = getFilteredSponsoredStories(stories, 10)
+
+ assertEquals(2, result.size)
+ assertEquals(stories[1], result[0])
+ assertEquals(stories[3], result[1])
+ }
+
+ @Test
+ fun `GIVEN a list of sponsored stories WHEN filtering them THEN drop the ones already shown for the maximum number of times in flight`() {
+ val stories = getFakeSponsoredStories(4).mapIndexed { index, story ->
+ when (index % 2 == 0) {
+ true -> story
+ false -> story.copy(
+ caps = story.caps.copy(
+ currentImpressions = listOf(
+ TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()),
+ TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()),
+ TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()),
+ ),
+ flightCount = 3,
+ ),
+ )
+ }
+ }
+
+ val result = getFilteredSponsoredStories(stories, 10)
+
+ assertEquals(2, result.size)
+ assertEquals(stories[0], result[0])
+ assertEquals(stories[2], result[1])
+ }
+
+ @Test
+ fun `GIVEN a list of sponsored stories WHEN filtering them THEN return up to limit of stories asked`() {
+ val stories = getFakeSponsoredStories(4)
+
+ val result = getFilteredSponsoredStories(stories, 2)
+
+ assertEquals(2, result.size)
+ }
+
+ @Test
+ fun `GIVEN multiple stories of both types WHEN combining them THEN show sponsored stories at positionn 2 and 8`() {
+ val recommendedStories = getFakePocketStories(POCKET_STORIES_TO_SHOW_COUNT, "other")
+ val sponsoredStories = getFakeSponsoredStories(4)
+
+ val result = combineRecommendedAndSponsoredStories(recommendedStories, sponsoredStories)
+
+ assertEquals(POCKET_STORIES_TO_SHOW_COUNT, result.size)
+ assertEquals(recommendedStories[0], result[0])
+ assertEquals(sponsoredStories[0], result[1])
+ assertEquals(recommendedStories[1], result[2])
+ assertEquals(recommendedStories[2], result[3])
+ assertEquals(recommendedStories[3], result[4])
+ assertEquals(recommendedStories[4], result[5])
+ assertEquals(recommendedStories[5], result[6])
+ assertEquals(sponsoredStories[1], result[POCKET_STORIES_TO_SHOW_COUNT - 1])
+ }
+
+ @Test
+ fun `GIVEN a category is selected and 1 sponsored story is available WHEN getFilteredStories is called THEN only stories from that category and the sponsored story are returned`() {
+ val sponsoredStories = getFakeSponsoredStories(1)
+ val state = AppState(
+ pocketStoriesCategories = listOf(otherStoriesCategory, anotherStoriesCategory, defaultStoriesCategory),
+ pocketStoriesCategoriesSelections = listOf(PocketRecommendedStoriesSelectedCategory(otherStoriesCategory.name)),
+ pocketSponsoredStories = sponsoredStories,
+ )
+
+ val result = state.getFilteredStories().toMutableList()
+
+ assertEquals(4, result.size)
+ assertEquals(sponsoredStories[0], result[1]) // second story should be a sponsored one
+ // remove the sponsored story to hopefully only remain with stories from the selected category
+ result.removeAt(1)
+ assertNull(
+ result.firstOrNull {
+ it is PocketRecommendedStory && it.category != otherStoriesCategory.name
+ },
+ )
+ }
+
+ @Test
+ fun `GIVEN two categories selected and 1 sponsored story available WHEN getFilteredStories is called THEN only stories from the selected categories and the sponsored story are returned`() {
+ val sponsoredStories = getFakeSponsoredStories(1)
+ val yetAnotherStoriesCategory =
+ PocketRecommendedStoriesCategory("yetAnother", getFakePocketStories(3, "yetAnother"))
+ val state = AppState(
+ pocketStoriesCategories = listOf(
+ otherStoriesCategory,
+ anotherStoriesCategory,
+ yetAnotherStoriesCategory,
+ defaultStoriesCategory,
+ ),
+ pocketStoriesCategoriesSelections = listOf(
+ PocketRecommendedStoriesSelectedCategory(otherStoriesCategory.name),
+ PocketRecommendedStoriesSelectedCategory(anotherStoriesCategory.name),
+ ),
+ pocketSponsoredStories = sponsoredStories,
+ )
+
+ val result = state.getFilteredStories().toMutableList()
+
+ // Only 7 stories available: 3*2 stories from the selected categories plus one sponsored story
+ assertEquals(7, result.size)
+ assertEquals(sponsoredStories[0], result[1]) // second story should be a sponsored one
+ // remove the sponsored story to hopefully only remain with stories from the selected category
+ result.removeAt(1)
+ assertNull(
+ result.firstOrNull {
+ (it !is PocketRecommendedStory) ||
+ (it.category != otherStoriesCategory.name && it.category != anotherStoriesCategory.name)
+ },
+ )
+ }
+
+ @Test
+ fun `GIVEN two categories selected and 2 sponsored stories available WHEN getFilteredStories is called THEN no more than the default stories number are returned`() {
+ val sponsoredStories = getFakeSponsoredStories(4)
+ val yetAnotherStoriesCategory =
+ PocketRecommendedStoriesCategory("yetAnother", getFakePocketStories(10, "yetAnother"))
+ val state = AppState(
+ pocketStoriesCategories = listOf(
+ otherStoriesCategory,
+ anotherStoriesCategory,
+ yetAnotherStoriesCategory,
+ defaultStoriesCategory,
+ ),
+ pocketStoriesCategoriesSelections = listOf(
+ PocketRecommendedStoriesSelectedCategory(otherStoriesCategory.name),
+ PocketRecommendedStoriesSelectedCategory(yetAnotherStoriesCategory.name),
+ ),
+ pocketSponsoredStories = sponsoredStories,
+ )
+
+ val result = state.getFilteredStories().toMutableList()
+
+ assertEquals(POCKET_STORIES_TO_SHOW_COUNT, result.size)
+ // second and penultimate story should be sponsored stories
+ assertEquals(sponsoredStories[1], result[1])
+ assertEquals(sponsoredStories[3], result[POCKET_STORIES_TO_SHOW_COUNT - 1])
+ // remove the sponsored stories to hopefully only remain with stories from the selected categories
+ result.removeAt(7)
+ result.removeAt(1)
+ assertNull(
+ result.firstOrNull {
+ (it !is PocketRecommendedStory) ||
+ (it.category != otherStoriesCategory.name && it.category != yetAnotherStoriesCategory.name)
+ },
+ )
+ }
+
+ @Test
+ fun `GIVEN a category is selected WHEN getFilteredStories is called THEN no more than the default stories number are returned from the selected category`() {
+ val otherStoriesCategoryWithManyStories =
+ PocketRecommendedStoriesCategory(
+ "other",
+ getFakePocketStories(POCKET_STORIES_TO_SHOW_COUNT + 2, "other"),
+ )
+ val state = AppState(
+ pocketStoriesCategories =
+ listOf(otherStoriesCategoryWithManyStories, anotherStoriesCategory, defaultStoriesCategory),
+ pocketStoriesCategoriesSelections =
+ listOf(PocketRecommendedStoriesSelectedCategory(otherStoriesCategoryWithManyStories.name)),
+ )
+
+ val result = state.getFilteredStories()
+
+ assertEquals(POCKET_STORIES_TO_SHOW_COUNT, result.size)
+ }
+
+ @Test
+ fun `GIVEN two categories are selected WHEN getFilteredStories is called THEN only stories from those categories are returned`() {
+ val state = AppState(
+ pocketStoriesCategories = listOf(otherStoriesCategory, anotherStoriesCategory, defaultStoriesCategory),
+ pocketStoriesCategoriesSelections = listOf(
+ PocketRecommendedStoriesSelectedCategory(otherStoriesCategory.name),
+ PocketRecommendedStoriesSelectedCategory(anotherStoriesCategory.name),
+ ),
+ )
+
+ val result = state.getFilteredStories()
+ assertEquals(6, result.size)
+ assertNull(
+ result.firstOrNull {
+ it is PocketRecommendedStory &&
+ it.category != otherStoriesCategory.name &&
+ it.category != anotherStoriesCategory.name
+ },
+ )
+ }
+
+ @Test
+ fun `GIVEN no category is selected WHEN getFilteredStoriesCount is called THEN return an empty result`() {
+ val result = getFilteredStoriesCount(emptyList(), 1)
+
+ assertTrue(result.isEmpty())
+ }
+
+ @Test
+ fun `GIVEN a category is selected WHEN getFilteredStoriesCount is called for at most the stories from this category THEN only stories count only from that category are returned`() {
+ var result = getFilteredStoriesCount(listOf(otherStoriesCategory), 2)
+ assertEquals(1, result.keys.size)
+ assertEquals(otherStoriesCategory.name, result.entries.first().key)
+ assertEquals(2, result[otherStoriesCategory.name])
+
+ result = getFilteredStoriesCount(listOf(otherStoriesCategory), 3)
+ assertEquals(1, result.keys.size)
+ assertEquals(otherStoriesCategory.name, result.entries.first().key)
+ assertEquals(3, result[otherStoriesCategory.name])
+ }
+
+ @Test
+ fun `GIVEN a category is selected WHEN getFilteredStoriesCount is called for more stories than in this category THEN return only that`() {
+ val result = getFilteredStoriesCount(listOf(otherStoriesCategory), 4)
+ assertEquals(1, result.keys.size)
+ assertEquals(otherStoriesCategory.name, result.entries.first().key)
+ assertEquals(3, result[otherStoriesCategory.name])
+ }
+
+ @Test
+ fun `GIVEN two categories are selected WHEN getFilteredStoriesCount is called for at most the stories count in both THEN only stories counts from those categories are returned`() {
+ var result = getFilteredStoriesCount(listOf(otherStoriesCategory, anotherStoriesCategory), 2)
+ assertEquals(2, result.keys.size)
+ assertTrue(
+ result.keys.containsAll(
+ listOf(
+ otherStoriesCategory.name,
+ anotherStoriesCategory.name,
+ ),
+ ),
+ )
+ assertEquals(1, result[otherStoriesCategory.name])
+ assertEquals(1, result[anotherStoriesCategory.name])
+
+ result = getFilteredStoriesCount(listOf(otherStoriesCategory, anotherStoriesCategory), 6)
+ assertEquals(2, result.keys.size)
+ assertTrue(
+ result.keys.containsAll(
+ listOf(
+ otherStoriesCategory.name,
+ anotherStoriesCategory.name,
+ ),
+ ),
+ )
+ assertEquals(3, result[otherStoriesCategory.name])
+ assertEquals(3, result[anotherStoriesCategory.name])
+ }
+
+ @Test
+ fun `GIVEN two categories are selected WHEN getFilteredStoriesCount is called for more results than stories in both THEN only stories counts from those categories are returned`() {
+ val result = getFilteredStoriesCount(listOf(otherStoriesCategory, anotherStoriesCategory), 8)
+ assertEquals(2, result.keys.size)
+ assertTrue(
+ result.keys.containsAll(
+ listOf(
+ otherStoriesCategory.name,
+ anotherStoriesCategory.name,
+ ),
+ ),
+ )
+ assertEquals(3, result[otherStoriesCategory.name])
+ assertEquals(3, result[anotherStoriesCategory.name])
+ }
+
+ @Test
+ fun `GIVEN two categories are selected WHEN getFilteredStoriesCount is called for an odd number of results THEN there are more by one results from first selected category`() {
+ val result = getFilteredStoriesCount(listOf(otherStoriesCategory, anotherStoriesCategory), 5)
+
+ assertTrue(
+ result.keys.containsAll(
+ listOf(
+ otherStoriesCategory.name,
+ anotherStoriesCategory.name,
+ ),
+ ),
+ )
+ assertEquals(3, result[otherStoriesCategory.name])
+ assertEquals(2, result[anotherStoriesCategory.name])
+ }
+
+ @Test
+ fun `GIVEN two categories selected with more than needed stories WHEN getFilteredStories is called THEN the results are sorted in the order of least shown`() {
+ val firstCategory = PocketRecommendedStoriesCategory(
+ "first",
+ getFakePocketStories(3, "first"),
+ ).run {
+ // Avoid the first item also being the oldest to eliminate a potential bug in code
+ // that would still get the expected result.
+ copy(
+ stories = stories.mapIndexed { index, story ->
+ when (index) {
+ 0 -> story.copy(timesShown = 333)
+ 1 -> story.copy(timesShown = 0)
+ else -> story.copy(timesShown = 345)
+ }
+ },
+ )
+ }
+ val secondCategory = PocketRecommendedStoriesCategory(
+ "second",
+ getFakePocketStories(3, "second"),
+ ).run {
+ // Avoid the first item also being the oldest to eliminate a potential bug in code
+ // that would still get the expected result.
+ copy(
+ stories = stories.mapIndexed { index, story ->
+ when (index) {
+ 0 -> story.copy(timesShown = 222)
+ 1 -> story.copy(timesShown = 111)
+ else -> story.copy(timesShown = 11)
+ }
+ },
+ )
+ }
+
+ val state = AppState(
+ pocketStoriesCategories = listOf(firstCategory, secondCategory),
+ pocketStoriesCategoriesSelections = listOf(
+ PocketRecommendedStoriesSelectedCategory(firstCategory.name, selectionTimestamp = 0),
+ PocketRecommendedStoriesSelectedCategory(secondCategory.name, selectionTimestamp = 222),
+ ),
+ )
+
+ val result = state.getFilteredStories()
+
+ assertEquals(6, result.size)
+ assertSame(secondCategory.stories[2], result.first())
+ assertSame(secondCategory.stories[1], result[1])
+ assertSame(secondCategory.stories[0], result[2])
+ assertSame(firstCategory.stories[1], result[3])
+ assertSame(firstCategory.stories[0], result[4])
+ assertSame(firstCategory.stories[2], result[5])
+ }
+
+ @Test
+ fun `GIVEN old selections of categories which do not exist anymore WHEN getFilteredStories is called THEN ignore not found selections`() {
+ val state = AppState(
+ pocketStoriesCategories = listOf(otherStoriesCategory, anotherStoriesCategory, defaultStoriesCategory),
+ pocketStoriesCategoriesSelections = listOf(
+ PocketRecommendedStoriesSelectedCategory("unexistent"),
+ PocketRecommendedStoriesSelectedCategory(anotherStoriesCategory.name),
+ ),
+ )
+
+ val result = state.getFilteredStories()
+
+ assertEquals(3, result.size)
+ assertNull(
+ result.firstOrNull {
+ it is PocketRecommendedStory && it.category != anotherStoriesCategory.name
+ },
+ )
+ }
+
+ @Test
+ fun `GIVEN recent tabs disabled in settings WHEN checking to show tabs THEN section should not be shown`() {
+ val settings = mockk<Settings> {
+ every { showRecentTabsFeature } returns false
+ }
+
+ val state = AppState()
+
+ Assert.assertFalse(state.shouldShowRecentTabs(settings))
+ }
+
+ @Test
+ fun `GIVEN only local tabs WHEN checking to show tabs THEN section should be shown`() {
+ val settings = mockk<Settings> {
+ every { showRecentTabsFeature } returns true
+ }
+
+ val state = AppState(recentTabs = listOf(mockk()))
+
+ assertTrue(state.shouldShowRecentTabs(settings))
+ }
+
+ @Test
+ fun `GIVEN only remote tabs WHEN checking to show tabs THEN section should be shown`() {
+ val settings = mockk<Settings> {
+ every { showRecentTabsFeature } returns true
+ }
+
+ val state = AppState(recentSyncedTabState = RecentSyncedTabState.Success(mockk()))
+
+ assertTrue(state.shouldShowRecentTabs(settings))
+ }
+
+ @Test
+ fun `GIVEN local and remote tabs WHEN checking to show tabs THEN section should be shown`() {
+ val settings = mockk<Settings> {
+ every { showRecentTabsFeature } returns true
+ }
+
+ val state = AppState(
+ recentTabs = listOf(mockk()),
+ recentSyncedTabState = RecentSyncedTabState.Success(mockk()),
+ )
+
+ assertTrue(state.shouldShowRecentTabs(settings))
+ }
+}
+
+private fun getFakePocketStories(
+ limit: Int = 1,
+ category: String = POCKET_STORIES_DEFAULT_CATEGORY_NAME,
+): List<PocketRecommendedStory> {
+ return mutableListOf<PocketRecommendedStory>().apply {
+ for (index in 0 until limit) {
+ val randomNumber = Random.nextInt(0, 10)
+
+ add(
+ PocketRecommendedStory(
+ title = "This is a ${"very ".repeat(randomNumber)} long title",
+ publisher = "Publisher",
+ url = "https://story$randomNumber.com",
+ imageUrl = "",
+ timeToRead = randomNumber,
+ category = category,
+ timesShown = index.toLong(),
+ ),
+ )
+ }
+ }
+}
+
+private fun getFakeSponsoredStories(limit: Int) = mutableListOf<PocketSponsoredStory>().apply {
+ for (index in 0 until limit) {
+ add(
+ PocketSponsoredStory(
+ id = index,
+ title = "Story title $index",
+ url = "https://sponsored.story",
+ imageUrl = "https://sponsored.image",
+ sponsor = "Sponsor $index",
+ shim = PocketSponsoredStoryShim(
+ click = "Story title $index click shim",
+ impression = "Story title $index impression shim",
+ ),
+ priority = 2 + index % 2,
+ caps = PocketSponsoredStoryCaps(
+ lifetimeCount = 1 + index * 5,
+ flightCount = 1 + index * 2,
+ flightPeriod = 1 + index * 3,
+ ),
+ ),
+ )
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/AtomicIntegerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/AtomicIntegerTest.kt
new file mode 100644
index 0000000000..fc30d9a51c
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/AtomicIntegerTest.kt
@@ -0,0 +1,40 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.ext
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.util.concurrent.atomic.AtomicInteger
+
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+class AtomicIntegerTest {
+
+ @Test
+ fun `Safely increment an AtomicInteger from different coroutines`() {
+ val integer = AtomicInteger(0)
+ runBlocking {
+ for (i in 1..2) {
+ launch(Dispatchers.Default) {
+ integer.getAndIncrementNoOverflow()
+ }
+ }
+ }
+
+ assertEquals(integer.get(), 2)
+ }
+
+ @Test
+ fun `Incrementing the AtomicInteger should not overflow`() {
+ val integer = AtomicInteger(Integer.MAX_VALUE)
+ integer.getAndIncrementNoOverflow()
+ assertEquals(integer.get(), Integer.MAX_VALUE)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/BrowserIconsTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/BrowserIconsTest.kt
new file mode 100644
index 0000000000..53078d268b
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/BrowserIconsTest.kt
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.ext
+
+import android.content.Context
+import android.widget.ImageView
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.browser.engine.gecko.fetch.GeckoViewFetchClient
+import mozilla.components.browser.icons.BrowserIcons
+import mozilla.components.browser.icons.IconRequest
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.lang.ref.WeakReference
+
+class BrowserIconsTest {
+ @Test
+ fun loadIntoViewTest() {
+ val myUrl = "https://mozilla.com"
+ val request = IconRequest(url = myUrl)
+ val imageView = mockk<ImageView>()
+ val context = mockk<Context>()
+ val weakReference = slot<WeakReference<ImageView>>()
+
+ every { context.assets } returns mockk()
+ every { context.resources.getDimensionPixelSize(any()) } returns 100
+
+ val icons = spyk(
+ BrowserIcons(context = context, httpClient = mockk<GeckoViewFetchClient>()),
+ )
+
+ every { icons.loadIntoViewInternal(any(), any(), any(), any()) } returns mockk()
+
+ icons.loadIntoView(imageView, myUrl)
+
+ verify { icons.loadIntoView(imageView, request) }
+ verify { icons.loadIntoViewInternal(capture(weakReference), request, null, null) }
+ assertEquals(imageView, weakReference.captured.get())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/BrowserStateTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/BrowserStateTest.kt
new file mode 100644
index 0000000000..346c129961
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/BrowserStateTest.kt
@@ -0,0 +1,383 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.ext
+
+import io.mockk.every
+import io.mockk.mockk
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.LastMediaAccessState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.concept.storage.HistoryMetadataKey
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.mozilla.fenix.home.recenttabs.RecentTab
+import org.mozilla.fenix.utils.Settings
+
+class BrowserStateTest {
+
+ @Test
+ fun `GIVEN a tab which had media playing WHEN inProgressMediaTab is called THEN return that tab`() {
+ val inProgressMediaTab = createTab(
+ url = "mediaUrl",
+ id = "2",
+ lastMediaAccessState = LastMediaAccessState("https://mozilla.com", 123, true),
+ )
+ val browserState = BrowserState(
+ tabs = listOf(mockk(relaxed = true), inProgressMediaTab, mockk(relaxed = true)),
+ )
+
+ assertEquals(inProgressMediaTab, browserState.inProgressMediaTab)
+ }
+
+ @Test
+ fun `GIVEN no tab which had media playing exists WHEN inProgressMediaTab is called THEN return null`() {
+ val browserState = BrowserState(
+ tabs = listOf(createTab("tab1"), createTab("tab2"), createTab("tab3")),
+ )
+
+ assertNull(browserState.inProgressMediaTab)
+ }
+
+ @Test
+ fun `GIVEN the selected tab is a normal tab and no media tab exists WHEN asRecentTabs is called THEN return a list of that tab`() {
+ val selectedTab = createTab(url = "url", id = "3")
+ val browserState = BrowserState(
+ tabs = listOf(createTab("tab1"), selectedTab, createTab("tab3")),
+ selectedTabId = selectedTab.id,
+ )
+
+ val result = browserState.asRecentTabs()
+
+ assertEquals(1, result.size)
+ assertEquals(selectedTab, (result[0] as RecentTab.Tab).state)
+ }
+
+ @Test
+ fun `GIVEN the selected tab is a private tab and no media tab exists WHEN asRecentTabs is called THEN return a list of the last accessed normal tab`() {
+ val selectedPrivateTab = createTab(url = "url", id = "1", lastAccess = 1, private = true)
+ val lastAccessedNormalTab = createTab(url = "url2", id = "2", lastAccess = 2)
+ val browserState = BrowserState(
+ tabs = listOf(
+ createTab("https://mozilla.org"),
+ lastAccessedNormalTab,
+ selectedPrivateTab,
+ ),
+ selectedTabId = selectedPrivateTab.id,
+ )
+
+ val result = browserState.asRecentTabs()
+
+ assertEquals(1, result.size)
+ assertEquals(lastAccessedNormalTab, (result[0] as RecentTab.Tab).state)
+ }
+
+ @Test
+ fun `GIVEN the selected tab is a normal tab and another media tab exists WHEN asRecentTabs is called THEN return a list of these tabs`() {
+ val selectedTab = createTab(url = "url", id = "3")
+ val mediaTab = createTab(
+ "mediaUrl",
+ id = "23",
+ lastMediaAccessState = LastMediaAccessState("https://mozilla.com", 123, true),
+ )
+ val browserState = BrowserState(
+ tabs = listOf(mockk(relaxed = true), selectedTab, mediaTab),
+ selectedTabId = selectedTab.id,
+ )
+
+ val result = browserState.asRecentTabs()
+
+ assertEquals(1, result.size)
+ assertEquals(selectedTab, (result[0] as RecentTab.Tab).state)
+ }
+
+ @Test
+ fun `GIVEN the selected tab is a private tab and another tab exists WHEN asRecentTabs is called THEN return a list of the last normal tab`() {
+ val lastAccessedNormalTab = createTab(url = "url2", id = "2", lastAccess = 2)
+ val selectedPrivateTab = createTab(url = "url", id = "1", lastAccess = 1, private = true)
+ val mediaTab = createTab(
+ "mediaUrl",
+ id = "12",
+ lastAccess = 0,
+ lastMediaAccessState = LastMediaAccessState("https://mozilla.com", 123, true),
+ )
+ val browserState = BrowserState(
+ tabs = listOf(
+ mockk(relaxed = true),
+ lastAccessedNormalTab,
+ selectedPrivateTab,
+ mediaTab,
+ ),
+ selectedTabId = selectedPrivateTab.id,
+ )
+
+ val result = browserState.asRecentTabs()
+
+ assertEquals(1, result.size)
+ assertEquals(lastAccessedNormalTab, (result[0] as RecentTab.Tab).state)
+ }
+
+ @Test
+ fun `GIVEN the selected tab is a private tab and the media tab is the last accessed normal tab WHEN asRecentTabs is called THEN return a list of the second-to-last normal tab`() {
+ val selectedPrivateTab = createTab(url = "url", id = "1", lastAccess = 1, private = true)
+ val normalTab = createTab(url = "url2", id = "2", lastAccess = 2)
+ val mediaTab = createTab(
+ "mediaUrl",
+ id = "12",
+ lastAccess = 20,
+ lastMediaAccessState = LastMediaAccessState("https://mozilla.com", 123, true),
+ )
+ val browserState = BrowserState(
+ tabs = listOf(mockk(relaxed = true), normalTab, selectedPrivateTab, mediaTab),
+ selectedTabId = selectedPrivateTab.id,
+ )
+
+ val result = browserState.asRecentTabs()
+
+ assertEquals(1, result.size)
+ assertEquals(mediaTab, (result[0] as RecentTab.Tab).state)
+ }
+
+ @Test
+ fun `GIVEN a tab group with one tab WHEN recentTabs is called THEN return a tab group`() {
+ val searchGroupTab = createTab(
+ url = "https://www.mozilla.org",
+ id = "1",
+ historyMetadata = HistoryMetadataKey(
+ url = "https://www.mozilla.org",
+ searchTerm = "Test",
+ referrerUrl = "https://www.mozilla.org",
+ ),
+ )
+ val browserState = BrowserState(
+ tabs = listOf(searchGroupTab),
+ selectedTabId = searchGroupTab.id,
+ )
+
+ val result = browserState.asRecentTabs()
+
+ assertEquals(1, result.size)
+ assertEquals(searchGroupTab, (result[0] as RecentTab.Tab).state)
+ }
+
+ @Test
+ fun `GIVEN the selected tab is a normal tab and tab group with one tab exists WHEN asRecentTabs is called THEN return only the normal tab`() {
+ val selectedTab = createTab(url = "url", id = "3")
+ val searchGroupTab = createTab(
+ url = "https://www.mozilla.org",
+ id = "4",
+ historyMetadata = HistoryMetadataKey(
+ url = "https://www.mozilla.org",
+ searchTerm = "Test",
+ referrerUrl = "https://www.mozilla.org",
+ ),
+ )
+ val searchGroupTab2 = createTab(
+ url = "https://www.mozilla.org",
+ id = "5",
+ historyMetadata = HistoryMetadataKey(
+ url = "https://www.firefox.com",
+ searchTerm = "Test",
+ referrerUrl = "https://www.mozilla.org",
+ ),
+ )
+ val browserState = BrowserState(
+ tabs = listOf(mockk(relaxed = true), selectedTab, searchGroupTab, searchGroupTab2),
+ selectedTabId = selectedTab.id,
+ )
+
+ val result = browserState.asRecentTabs()
+
+ assertEquals(1, result.size)
+ assertEquals(selectedTab, (result[0] as RecentTab.Tab).state)
+ }
+
+ @Test
+ fun `GIVEN only private tabs and a private one selected WHEN lastOpenedNormalTab is called THEN return null`() {
+ val selectedPrivateTab = createTab(url = "url", id = "1", private = true)
+ val otherPrivateTab = createTab(url = "url2", id = "2", private = true)
+ val browserState = BrowserState(
+ tabs = listOf(selectedPrivateTab, otherPrivateTab),
+ selectedTabId = "1",
+ )
+
+ assertNull(browserState.lastOpenedNormalTab)
+ }
+
+ @Test
+ fun `GIVEN normal tabs exists but a private one is selected WHEN lastOpenedNormalTab is called THEN return the last accessed normal tab`() {
+ val selectedPrivateTab = createTab(url = "url", id = "1", private = true)
+ val normalTab1 = createTab(url = "url2", id = "2", private = false, lastAccess = 2)
+ val normalTab2 = createTab(url = "url3", id = "3", private = false, lastAccess = 3)
+ val browserState = BrowserState(
+ tabs = listOf(selectedPrivateTab, normalTab1, normalTab2),
+ selectedTabId = "3",
+ )
+
+ assertEquals(normalTab2, browserState.lastOpenedNormalTab)
+ }
+
+ @Test
+ fun `GIVEN a normal tab is selected WHEN lastOpenedNormalTab is called THEN return the selected normal tab`() {
+ val normalTab1 = createTab(url = "url1", id = "1", private = false)
+ val normalTab2 = createTab(url = "url2", id = "2", private = false)
+ val browserState = BrowserState(
+ tabs = listOf(normalTab1, normalTab2),
+ selectedTabId = "1",
+ )
+
+ assertEquals(normalTab1, browserState.lastOpenedNormalTab)
+ }
+
+ @Test
+ fun `GIVEN no normal tabs are open WHEN secondToLastOpenedNormalTab is called THEN return null`() {
+ val browserState = BrowserState(
+ tabs = listOf(mockk(relaxed = true)),
+ )
+ assertNull(browserState.secondToLastOpenedNormalTab)
+ }
+
+ @Test
+ fun `GIVEN one normal tab is open WHEN secondToLastOpenedNormalTab is called THEN return the one tab`() {
+ val lastAccessedNormalTab = createTab(url = "url2", id = "2", lastAccess = 1)
+ val browserState = BrowserState(
+ tabs = listOf(lastAccessedNormalTab),
+ )
+ assertNull(browserState.secondToLastOpenedNormalTab)
+ }
+
+ @Test
+ fun `GIVEN two normal tabs are open WHEN secondToLastOpenedNormalTab is called THEN return the second-to-last opened tab`() {
+ val normalTab1 = createTab(url = "url1", id = "1", lastAccess = 1)
+ val normalTab2 = createTab(url = "url2", id = "2", lastAccess = 2)
+ val browserState = BrowserState(
+ tabs = listOf(normalTab1, normalTab2),
+ )
+ assertEquals(normalTab1.id, browserState.secondToLastOpenedNormalTab!!.id)
+ }
+
+ @Test
+ fun `GIVEN four normal tabs are open WHEN secondToLastOpenedNormalTab is called THEN return the second-to-last opened tab`() {
+ val normalTab1 = createTab(url = "url1", id = "1", lastAccess = 1)
+ val normalTab2 = createTab(url = "url2", id = "2", lastAccess = 4)
+ val normalTab3 = createTab(url = "url3", id = "3", lastAccess = 3)
+ val normalTab4 = createTab(url = "url4", id = "4", lastAccess = 2)
+ val browserState = BrowserState(
+ tabs = listOf(normalTab1, normalTab2, normalTab3, normalTab4),
+ )
+ assertEquals(normalTab3.id, browserState.secondToLastOpenedNormalTab!!.id)
+ }
+
+ @Test
+ fun `GIVEN a list of tabs WHEN potentialInactiveTabs is called THEN return the normal tabs which haven't been active lately`() {
+ // An inactive tab is one created or accessed more than [maxActiveTime] ago
+ // checked against [System.currentTimeMillis]
+ //
+ // createTab() sets the [createdTime] property to [System.currentTimeMillis] this meaning
+ // that the default tab is considered active.
+
+ val normalTab1 = createTab(url = "url1", id = "1", createdAt = 1)
+ val normalTab2 = createTab(url = "url2", id = "2")
+ val normalTab3 = createTab(url = "url3", id = "3", createdAt = 1)
+ val normalTab4 = createTab(url = "url4", id = "4")
+ val privateTab1 = createTab(url = "url1", id = "6", private = true)
+ val privateTab2 = createTab(url = "url2", id = "7", private = true, createdAt = 1)
+ val privateTab3 = createTab(url = "url3", id = "8", private = true)
+ val privateTab4 = createTab(url = "url4", id = "9", private = true, createdAt = 1)
+ val browserState = BrowserState(
+ tabs = listOf(
+ normalTab1,
+ normalTab2,
+ normalTab3,
+ normalTab4,
+ privateTab1,
+ privateTab2,
+ privateTab3,
+ privateTab4,
+ ),
+ )
+
+ val result = browserState.potentialInactiveTabs
+
+ assertEquals(2, result.size)
+ assertTrue(result.containsAll(listOf(normalTab1, normalTab3)))
+ }
+
+ @Test
+ fun `GIVEN inactiveTabs feature is disabled WHEN actualInactiveTabs is called THEN return an empty result`() {
+ // An inactive tab is one created or accessed more than [maxActiveTime] ago
+ // checked against [System.currentTimeMillis]
+ //
+ // createTab() sets the [createdTime] property to [System.currentTimeMillis] this meaning
+ // that the default tab is considered active.
+
+ val normalTab1 = createTab(url = "url1", id = "1", createdAt = 1)
+ val normalTab2 = createTab(url = "url2", id = "2")
+ val normalTab3 = createTab(url = "url3", id = "3", createdAt = 1)
+ val normalTab4 = createTab(url = "url4", id = "4")
+ val privateTab1 = createTab(url = "url1", id = "6", private = true)
+ val privateTab2 = createTab(url = "url2", id = "7", private = true, createdAt = 1)
+ val privateTab3 = createTab(url = "url3", id = "8", private = true)
+ val privateTab4 = createTab(url = "url4", id = "9", private = true, createdAt = 1)
+ val browserState = BrowserState(
+ tabs = listOf(
+ normalTab1,
+ normalTab2,
+ normalTab3,
+ normalTab4,
+ privateTab1,
+ privateTab2,
+ privateTab3,
+ privateTab4,
+ ),
+ )
+ val settings: Settings = mockk() {
+ every { inactiveTabsAreEnabled } returns false
+ }
+
+ val result = browserState.actualInactiveTabs(settings)
+
+ assertTrue(result.isEmpty())
+ }
+
+ @Test
+ fun `GIVEN inactiveTabs feature is enabled WHEN actualInactiveTabs is called THEN return the normal tabs which haven't been active lately`() {
+ // An inactive tab is one created or accessed more than [maxActiveTime] ago
+ // checked against [System.currentTimeMillis]
+ //
+ // createTab() sets the [createdTime] property to [System.currentTimeMillis] this meaning
+ // that the default tab is considered active.
+
+ val normalTab1 = createTab(url = "url1", id = "1", createdAt = 1)
+ val normalTab2 = createTab(url = "url2", id = "2")
+ val normalTab3 = createTab(url = "url3", id = "3", createdAt = 1)
+ val normalTab4 = createTab(url = "url4", id = "4")
+ val privateTab1 = createTab(url = "url1", id = "6", private = true)
+ val privateTab2 = createTab(url = "url2", id = "7", private = true, createdAt = 1)
+ val privateTab3 = createTab(url = "url3", id = "8", private = true)
+ val privateTab4 = createTab(url = "url4", id = "9", private = true, createdAt = 1)
+ val browserState = BrowserState(
+ tabs = listOf(
+ normalTab1,
+ normalTab2,
+ normalTab3,
+ normalTab4,
+ privateTab1,
+ privateTab2,
+ privateTab3,
+ privateTab4,
+ ),
+ )
+ val settings: Settings = mockk() {
+ every { inactiveTabsAreEnabled } returns true
+ }
+
+ val result = browserState.actualInactiveTabs(settings)
+
+ assertEquals(2, result.size)
+ assertTrue(result.containsAll(listOf(normalTab1, normalTab3)))
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ConfigurationKtTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ConfigurationKtTest.kt
new file mode 100644
index 0000000000..909d38d726
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ConfigurationKtTest.kt
@@ -0,0 +1,34 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.ext
+
+import io.mockk.mockk
+import mozilla.components.service.glean.config.Configuration
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Test
+
+class ConfigurationKtTest {
+ @Test
+ fun `GIVEN server endpoint is null THEN return the same configuration`() {
+ val configuration = Configuration(httpClient = mockk())
+
+ assertEquals(configuration, configuration.setCustomEndpointIfAvailable(null))
+ }
+
+ @Test
+ fun `GIVEN server endpoint is not empty THEN make a copy of configuration with server endpoint`() {
+ val configuration = Configuration(httpClient = mockk())
+
+ assertNotEquals(configuration, configuration.setCustomEndpointIfAvailable("test"))
+ }
+
+ @Test
+ fun `GIVEN server endpoint is empty THEN return the same configuration`() {
+ val configuration = Configuration(httpClient = mockk())
+
+ assertEquals(configuration, configuration.setCustomEndpointIfAvailable(""))
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ConnectivityManagerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ConnectivityManagerTest.kt
new file mode 100644
index 0000000000..ec16d63344
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ConnectivityManagerTest.kt
@@ -0,0 +1,79 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.ext
+
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class ConnectivityManagerTest {
+
+ private lateinit var connectivityManager: ConnectivityManager
+
+ @Before
+ fun setup() {
+ connectivityManager = mockk(relaxed = true)
+
+ mockkStatic("org.mozilla.fenix.ext.ConnectivityManagerKt")
+ }
+
+ @Test
+ fun `connectManager is online works`() {
+ val network = mockk<Network>()
+ val networkCapabilities = mockk<NetworkCapabilities>()
+
+ every { connectivityManager.getNetworkCapabilities(network) } returns networkCapabilities
+ every { networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns true
+ every { networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) } returns true
+
+ assertTrue(connectivityManager.isOnline(network))
+ }
+
+ @Test
+ fun `connectManager is online with null network works`() {
+ val network: Network? = null
+ val networkCapabilities = mockk<NetworkCapabilities>()
+
+ every { connectivityManager.getNetworkCapabilities(network) } returns networkCapabilities
+ every { networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns true
+ every { networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) } returns true
+
+ assertFalse(connectivityManager.isOnline(network))
+ }
+
+ @Test
+ fun `connectManager is online with unvalidated connection works`() {
+ val network = mockk<Network>()
+ val networkCapabilities = mockk<NetworkCapabilities>()
+
+ every { connectivityManager.getNetworkCapabilities(network) } returns networkCapabilities
+ every { networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns true
+ every { networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) } returns false
+
+ assertFalse(connectivityManager.isOnline(network))
+ }
+
+ @Test
+ fun `connectManager is online with no connection works`() {
+ val network = mockk<Network>()
+ val networkCapabilities = mockk<NetworkCapabilities>()
+
+ every { connectivityManager.getNetworkCapabilities(network) } returns networkCapabilities
+ every { networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns false
+ every { networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) } returns true
+
+ assertFalse(connectivityManager.isOnline(network))
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ContextTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ContextTest.kt
new file mode 100644
index 0000000000..cc3b754a50
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ContextTest.kt
@@ -0,0 +1,169 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.ext
+
+import android.app.Activity
+import android.content.Context
+import android.view.ContextThemeWrapper
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.test.core.app.ApplicationProvider
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.unmockkObject
+import mozilla.components.support.locale.LocaleManager
+import mozilla.components.support.locale.LocaleManager.getSystemDefault
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.FenixApplication
+import org.mozilla.fenix.R
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import java.lang.String.format
+import java.util.Locale
+
+@RunWith(FenixRobolectricTestRunner::class)
+class ContextTest {
+
+ private lateinit var mockContext: Context
+ private val selectedLocale = Locale("ro", "RO")
+ private val appName = "Firefox Preview"
+
+ private val mockId: Int = 11
+
+ @Before
+ fun setup() {
+ mockkObject(LocaleManager)
+
+ mockContext = mockk(relaxed = true)
+ mockContext.resources.configuration.setLocale(selectedLocale)
+
+ every { LocaleManager.getCurrentLocale(mockContext) } returns selectedLocale
+ }
+
+ @After
+ fun teardown() {
+ unmockkObject(LocaleManager)
+ }
+
+ @Test
+ fun `getStringWithArgSafe returns selected locale for correct formatted string`() {
+ val correctlyFormattedString = "Incearca noul %1s"
+ every { mockContext.getString(mockId) } returns correctlyFormattedString
+
+ val result = mockContext.getStringWithArgSafe(mockId, appName)
+
+ assertEquals("Incearca noul Firefox Preview", result)
+ }
+
+ @Test
+ fun `getStringWithArgSafe returns English locale for incorrect formatted string`() {
+ val englishString = "Try the new %1s"
+ val incorrectlyFormattedString = "Incearca noul %1&amp;s"
+ every { getSystemDefault() } returns Locale("en")
+ every { mockContext.getString(mockId) } returns incorrectlyFormattedString
+ every { format(mockContext.getString(mockId), appName) } returns format(englishString, appName)
+
+ val result = mockContext.getStringWithArgSafe(mockId, appName)
+
+ assertEquals("Try the new Firefox Preview", result)
+ }
+
+ @Test
+ fun `GIVEN context WHEN seeking application of context THEN send back application context`() {
+ val expectedAppValue = ApplicationProvider.getApplicationContext<FenixApplication>()
+ assertEquals(expectedAppValue, testContext.application)
+ }
+
+ @Test
+ fun `GIVEN context WHEN requiring components THEN send back application components`() {
+ val expectedComponentsValue = ApplicationProvider.getApplicationContext<FenixApplication>().components
+ assertEquals(expectedComponentsValue, testContext.components)
+ }
+
+ @Test
+ fun `GIVEN context WHEN getting metrics controller THEN send back metrics`() {
+ every { testContext.components.analytics } returns mockk(relaxed = true)
+ val expectedMetricsValue = ApplicationProvider.getApplicationContext<FenixApplication>().components.analytics.metrics
+ assertEquals(expectedMetricsValue, testContext.metrics)
+ }
+
+ @Test
+ fun `GIVEN activity context WHEN make it an activity THEN return activity`() {
+ val mockActivity = mockk<Activity> {
+ every { baseContext } returns null
+ }
+ val mockContext: Context = mockActivity
+ assertEquals(mockActivity, mockContext.asActivity())
+ }
+
+ @Test
+ fun `GIVEN theme wrapper context WHEN make it an activity THEN return base`() {
+ val mockActivity = mockk<Activity>()
+ val mockThemeWrapper = mockk<ContextThemeWrapper> {
+ every { baseContext } returns mockActivity
+ }
+ val mockContext: Context = mockThemeWrapper
+ assertEquals(mockActivity, mockContext.asActivity())
+ }
+
+ @Test
+ fun `GIVEN theme wrapper context without activity base context WHEN make it an activity THEN return null`() {
+ val mockThemeWrapper = mockk<ContextThemeWrapper> {
+ every { baseContext } returns mockk<FenixApplication>()
+ }
+ val mockContext: Context = mockThemeWrapper
+ assertNull(mockContext.asActivity())
+ }
+
+ @Test
+ fun `GIVEN activity context WHEN get root view THEN return content view`() {
+ val rootView = mockk<ViewGroup>()
+ val mockActivity = mockk<Activity> {
+ every { baseContext } returns null
+ every { window } returns mockk {
+ every { decorView } returns mockk {
+ every { findViewById<View>(android.R.id.content) } returns rootView
+ }
+ }
+ }
+ assertEquals(rootView, mockActivity.getRootView())
+ }
+
+ @Test
+ fun `GIVEN activity context without window WHEN get root view THEN return content view`() {
+ val mockActivity = mockk<Activity> {
+ every { baseContext } returns null
+ every { window } returns null
+ }
+ assertNull(mockActivity.getRootView())
+ }
+
+ @Test
+ fun `GIVEN activity context without valid content view WHEN get root view THEN return content view`() {
+ val mockActivity = mockk<Activity> {
+ every { baseContext } returns null
+ every { window } returns mockk {
+ every { decorView } returns mockk {
+ every { findViewById<View>(android.R.id.content) } returns mockk<TextView>()
+ }
+ }
+ }
+ assertNull(mockActivity.getRootView())
+ }
+
+ @Test
+ fun `GIVEN context WHEN given a preference key THEN send back the right string`() {
+ val comparisonStr = testContext.getString(R.string.private_browsing_common_myths)
+ val actualStr = testContext.getPreferenceKey(R.string.private_browsing_common_myths)
+ assertEquals(comparisonStr, actualStr)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/DownloadItemKtTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/DownloadItemKtTest.kt
new file mode 100644
index 0000000000..5801bcb6e9
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/DownloadItemKtTest.kt
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.ext
+
+import mozilla.components.browser.state.state.content.DownloadState
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.mozilla.fenix.R
+import org.mozilla.fenix.library.downloads.DownloadItem
+
+class DownloadItemKtTest {
+ @Test
+ fun getIcon() {
+ val downloadItem = DownloadItem(
+ id = "0",
+ url = "url",
+ fileName = "MyAwesomeFile",
+ filePath = "",
+ size = "",
+ contentType = "image/png",
+ status = DownloadState.Status.COMPLETED,
+ )
+
+ assertEquals(R.drawable.ic_file_type_image, downloadItem.getIcon())
+ assertEquals(R.drawable.ic_file_type_audio_note, downloadItem.copy(contentType = "audio/mp3").getIcon())
+ assertEquals(R.drawable.ic_file_type_video, downloadItem.copy(contentType = "video/mp4").getIcon())
+ assertEquals(R.drawable.ic_file_type_document, downloadItem.copy(contentType = "text/csv").getIcon())
+ assertEquals(R.drawable.ic_file_type_zip, downloadItem.copy(contentType = "application/gzip").getIcon())
+ assertEquals(R.drawable.ic_file_type_apk, downloadItem.copy(contentType = null, fileName = "Fenix.apk").getIcon())
+ assertEquals(R.drawable.ic_file_type_zip, downloadItem.copy(contentType = null, fileName = "Fenix.zip").getIcon())
+ assertEquals(R.drawable.ic_file_type_document, downloadItem.copy(contentType = null, fileName = "Fenix.pdf").getIcon())
+ assertEquals(R.drawable.ic_file_type_default, downloadItem.copy(contentType = null, fileName = null).getIcon())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/DrawableTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/DrawableTest.kt
new file mode 100644
index 0000000000..960435c2ed
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/DrawableTest.kt
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.ext
+
+import android.graphics.Canvas
+import android.graphics.ColorFilter
+import android.graphics.Rect
+import android.graphics.drawable.Drawable
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class DrawableTest {
+ @Test
+ fun testSetBounds() {
+ val drawable = TestDrawable()
+ assertFalse(drawable.boundsChanged)
+
+ val size = 10
+ drawable.setBounds(size)
+ assertTrue(drawable.boundsChanged)
+
+ val returnRec = drawable.copyBounds()
+ assertTrue(returnRec.contains(0, 0, -10, 10))
+ }
+
+ private class TestDrawable : Drawable() {
+ var boundsChanged: Boolean = false
+ override fun getOpacity(): Int {
+ return 0
+ }
+
+ override fun draw(canvas: Canvas) {}
+ override fun setAlpha(alpha: Int) {}
+ override fun setColorFilter(cf: ColorFilter?) {}
+ override fun onBoundsChange(bounds: Rect) {
+ boundsChanged = true
+ super.onBoundsChange(bounds)
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/FragmentTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/FragmentTest.kt
new file mode 100644
index 0000000000..9d0e08ecf9
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/FragmentTest.kt
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.ext
+
+import androidx.fragment.app.Fragment
+import androidx.navigation.NavController
+import androidx.navigation.NavDestination
+import androidx.navigation.NavDirections
+import androidx.navigation.NavOptions
+import androidx.navigation.Navigator.Extras
+import androidx.navigation.fragment.findNavController
+import io.mockk.Runs
+import io.mockk.confirmVerified
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class FragmentTest {
+
+ private val navDirections: NavDirections = mockk(relaxed = true)
+ private val mockDestination = spyk(NavDestination("hi"))
+ private val mockExtras: Extras = mockk(relaxed = true)
+ private val mockId = 4
+ private val navController = spyk(NavController(testContext))
+ private val mockFragment: Fragment = mockk(relaxed = true)
+ private val mockOptions: NavOptions = mockk(relaxed = true)
+
+ @Before
+ fun setup() {
+ mockkStatic("androidx.navigation.fragment.FragmentKt")
+ every { (mockFragment.findNavController()) } returns navController
+ every { (mockFragment.findNavController().currentDestination) } returns mockDestination
+ every { (mockDestination.id) } returns mockId
+ every { (navController.currentDestination) } returns mockDestination
+ every { (mockFragment.findNavController().currentDestination?.id) } answers { (mockDestination.id) }
+ }
+
+ @Test
+ fun `Test nav fun with ID and directions`() {
+ every { (mockFragment.findNavController().navigate(navDirections, null)) } just Runs
+
+ mockFragment.nav(mockId, navDirections)
+ verify { (mockFragment.findNavController().currentDestination) }
+ verify { (mockFragment.findNavController().navigate(navDirections, null)) }
+ confirmVerified(mockFragment)
+ }
+
+ @Test
+ fun `Test nav fun with ID, directions, and options`() {
+ every { (mockFragment.findNavController().navigate(navDirections, mockOptions)) } just Runs
+
+ mockFragment.nav(mockId, navDirections, mockOptions)
+ verify { (mockFragment.findNavController().currentDestination) }
+ verify { (mockFragment.findNavController().navigate(navDirections, mockOptions)) }
+ confirmVerified(mockFragment)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ImageButtonTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ImageButtonTest.kt
new file mode 100644
index 0000000000..f532951963
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ImageButtonTest.kt
@@ -0,0 +1,49 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.ext
+
+import android.view.View
+import android.widget.ImageButton
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+
+class ImageButtonTest {
+ private val imageButton: ImageButton = mockk()
+
+ @Before
+ fun setup() {
+ every { imageButton.visibility = any() } just Runs
+ every { imageButton.isEnabled = any() } just Runs
+ }
+
+ @Test
+ fun `Hide and disable`() {
+ imageButton.hideAndDisable()
+
+ verify { imageButton.isEnabled = false }
+ verify { imageButton.visibility = View.INVISIBLE }
+ }
+
+ @Test
+ fun `Show and enable`() {
+ imageButton.showAndEnable()
+
+ verify { imageButton.isEnabled = true }
+ verify { imageButton.visibility = View.VISIBLE }
+ }
+
+ @Test
+ fun `Remove and disable`() {
+ imageButton.removeAndDisable()
+
+ verify { imageButton.isEnabled = false }
+ verify { imageButton.visibility = View.GONE }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ListTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ListTest.kt
new file mode 100644
index 0000000000..cac1c5fa76
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ListTest.kt
@@ -0,0 +1,122 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.ext
+
+import mozilla.components.browser.state.state.content.DownloadState
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.mozilla.fenix.library.downloads.DownloadItem
+import java.io.File
+
+class ListTest {
+
+ @Test
+ fun `Test download in list but not on disk removed from list`() {
+ val filePath1 = "filepath.txt"
+ val filePath3 = "filepath3.txt"
+
+ val file1 = File(filePath1)
+ val file3 = File(filePath3)
+
+ // Create files
+ file1.createNewFile()
+ file3.createNewFile()
+
+ val item1 = DownloadItem(
+ id = "71",
+ url = "url",
+ fileName = "filepath.txt",
+ filePath = filePath1,
+ size = "71 Mb",
+ contentType = "Image/png",
+ status = DownloadState.Status.COMPLETED,
+ )
+ val item2 = DownloadItem(
+ id = "71",
+ url = "url",
+ fileName = "filepath2.txt",
+ filePath = "filepath2.txt",
+ size = "71 Mb",
+ contentType = "Image/png",
+ status = DownloadState.Status.COMPLETED,
+ )
+ val item3 = DownloadItem(
+ id = "71",
+ url = "url",
+ fileName = "filepath3.txt",
+ filePath = filePath3,
+ size = "71 Mb",
+ contentType = "Image/png",
+ status = DownloadState.Status.COMPLETED,
+ )
+
+ val testList = mutableListOf(item1, item2, item3)
+ val comparisonList: MutableList<DownloadItem> = mutableListOf(item1, item3)
+
+ val resultList = testList.filterNotExistsOnDisk()
+
+ assertEquals(comparisonList, resultList)
+
+ // Cleanup files
+ file1.delete()
+ file3.delete()
+ }
+
+ @Test
+ fun `Test download in list and on disk remain in list`() {
+ val filePath1 = "filepath.txt"
+ val filePath2 = "filepath.txt"
+ val filePath3 = "filepath3.txt"
+
+ val file1 = File(filePath1)
+ val file2 = File(filePath2)
+ val file3 = File(filePath3)
+
+ // Create files
+ file1.createNewFile()
+ file2.createNewFile()
+ file3.createNewFile()
+
+ val item1 = DownloadItem(
+ id = "71",
+ url = "url",
+ fileName = "filepath.txt",
+ filePath = filePath1,
+ size = "71 Mb",
+ contentType = "text/plain",
+ status = DownloadState.Status.COMPLETED,
+ )
+ val item2 = DownloadItem(
+ id = "72",
+ url = "url",
+ fileName = "filepath2.txt",
+ filePath = filePath2,
+ size = "71 Mb",
+ contentType = "text/plain",
+ status = DownloadState.Status.COMPLETED,
+ )
+ val item3 = DownloadItem(
+ id = "73",
+ url = "url",
+ fileName = "filepath3.txt",
+ filePath = filePath3,
+ size = "71 Mb",
+ contentType = "text/plain",
+ status = DownloadState.Status.COMPLETED,
+ )
+
+ val testList = mutableListOf(item1, item2, item3)
+ val comparisonList: MutableList<DownloadItem> = mutableListOf(item1, item2, item3)
+
+ val resultList = testList.filterNotExistsOnDisk()
+
+ assertEquals(comparisonList, resultList)
+
+ // Cleanup files
+ file1.delete()
+ file2.delete()
+ file3.delete()
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/LogTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/LogTest.kt
new file mode 100644
index 0000000000..1dc43726eb
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/LogTest.kt
@@ -0,0 +1,75 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.ext
+
+import android.util.Log
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.mockkStatic
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.Config
+import org.mozilla.fenix.ReleaseChannel
+
+class LogTest {
+
+ private val mockThrowable: Throwable = mockk()
+
+ @Before
+ fun setup() {
+ mockkStatic(Log::class)
+ mockkObject(Config)
+
+ every { Log.d(any(), any()) } returns 0
+ every { Log.w(any(), any<String>()) } returns 0
+ every { Log.d(any(), any(), any()) } returns 0
+ every { Log.d(any(), any(), any()) } returns 0
+ }
+
+ @Test
+ fun `Test log debug function`() {
+ every { Config.channel } returns ReleaseChannel.Debug
+ logDebug("hi", "hi")
+ verify { Log.d("hi", "hi") }
+ }
+
+ @Test
+ fun `Test log warn function with tag and message args`() {
+ every { Config.channel } returns ReleaseChannel.Debug
+ logWarn("hi", "hi")
+ verify { Log.w("hi", "hi") }
+ }
+
+ @Test
+ fun `Test log warn function with tag, message, and exception args`() {
+ every { Config.channel } returns ReleaseChannel.Debug
+ logWarn("hi", "hi", mockThrowable)
+ verify { Log.w("hi", "hi", mockThrowable) }
+ }
+
+ @Test
+ fun `Test log error function with tag, message, and exception args`() {
+ every { Config.channel } returns ReleaseChannel.Debug
+ logErr("hi", "hi", mockThrowable)
+ verify { Log.e("hi", "hi", mockThrowable) }
+ }
+
+ @Test
+ fun `Test no log in production channel`() {
+ every { Config.channel } returns ReleaseChannel.Release
+
+ logDebug("hi", "hi")
+ logWarn("hi", "hi")
+ logWarn("hi", "hi", mockThrowable)
+ logErr("hi", "hi", mockThrowable)
+
+ verify(exactly = 0) { Log.d(any(), any()) }
+ verify(exactly = 0) { Log.w(any(), any<String>()) }
+ verify(exactly = 0) { Log.d(any(), any(), any()) }
+ verify(exactly = 0) { Log.d(any(), any(), any()) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/MockKMatcherScope.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/MockKMatcherScope.kt
new file mode 100644
index 0000000000..ace18a0e70
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/MockKMatcherScope.kt
@@ -0,0 +1,58 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.ext
+
+import android.content.Intent
+import androidx.navigation.NavDirections
+import androidx.navigation.NavOptions
+import io.mockk.Matcher
+import io.mockk.MockKMatcherScope
+import io.mockk.internalSubstitute
+import mozilla.components.support.ktx.android.os.contentEquals
+
+/**
+ * Verify that an equal [NavDirections] object was passed in a MockK verify call.
+ */
+fun MockKMatcherScope.directionsEq(value: NavDirections) = match(EqNavDirectionsMatcher(value))
+
+/**
+ * Verify that an equal [NavOptions] object was passed in a MockK verify call.
+ */
+fun MockKMatcherScope.optionsEq(value: NavOptions) = match(EqNavOptionsMatcher(value))
+
+/**
+ * Verify that two intents are the same for the purposes of intent resolution (filtering).
+ * Checks if their action, data, type, identity, class, and categories are the same.
+ * Does not compare extras.
+ */
+fun MockKMatcherScope.intentFilterEq(value: Intent) = match(EqIntentFilterMatcher(value))
+
+private data class EqNavDirectionsMatcher(private val value: NavDirections) : Matcher<NavDirections> {
+
+ override fun match(arg: NavDirections?): Boolean =
+ value.actionId == arg?.actionId && value.arguments contentEquals arg.arguments
+
+ override fun substitute(map: Map<Any, Any>) =
+ copy(value = value.internalSubstitute(map))
+}
+
+private data class EqNavOptionsMatcher(private val value: NavOptions) : Matcher<NavOptions> {
+
+ override fun match(arg: NavOptions?): Boolean =
+ value.popUpToId == arg?.popUpToId && value.isPopUpToInclusive() == arg.isPopUpToInclusive()
+
+ override fun substitute(map: Map<Any, Any>) =
+ copy(value = value.internalSubstitute(map))
+}
+
+private data class EqIntentFilterMatcher(private val value: Intent) : Matcher<Intent> {
+
+ override fun match(arg: Intent?): Boolean = value.filterEquals(arg)
+
+ override fun substitute(map: Map<Any, Any>) =
+ copy(value = value.internalSubstitute(map))
+
+ override fun toString() = "intentFilterEq($value)"
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/NavControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/NavControllerTest.kt
new file mode 100644
index 0000000000..19d066a7c2
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/NavControllerTest.kt
@@ -0,0 +1,52 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.ext
+
+import androidx.navigation.NavController
+import androidx.navigation.NavDestination
+import androidx.navigation.NavDirections
+import androidx.navigation.NavOptions
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+
+class NavControllerTest {
+
+ private val currentDestId = 4
+
+ @MockK(relaxUnitFun = true)
+ private lateinit var navController: NavController
+
+ @MockK private lateinit var navDirections: NavDirections
+
+ @MockK private lateinit var mockDestination: NavDestination
+
+ @MockK private lateinit var mockOptions: NavOptions
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+
+ every { navController.currentDestination } returns mockDestination
+ every { mockDestination.id } returns currentDestId
+ }
+
+ @Test
+ fun `Nav with id and directions args`() {
+ navController.nav(currentDestId, navDirections)
+ verify { navController.currentDestination }
+ verify { navController.navigate(navDirections, null) }
+ }
+
+ @Test
+ fun `Nav with id, directions, and options args`() {
+ navController.nav(currentDestId, navDirections, mockOptions)
+ verify { navController.currentDestination }
+ verify { navController.navigate(navDirections, mockOptions) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/SearchEngineTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/SearchEngineTest.kt
new file mode 100644
index 0000000000..826028b3d6
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/SearchEngineTest.kt
@@ -0,0 +1,67 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.ext
+
+import io.mockk.mockk
+import mozilla.components.browser.state.search.SearchEngine
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.util.UUID
+
+class SearchEngineTest {
+
+ @Test
+ fun `custom search engines are identified correctly`() {
+ val searchEngine = SearchEngine(
+ id = UUID.randomUUID().toString(),
+ name = "Not custom",
+ icon = mockk(),
+ type = SearchEngine.Type.BUNDLED,
+ resultUrls = listOf(
+ "https://www.startpage.com/sp/search?q={searchTerms}",
+ ),
+ )
+
+ val customSearchEngine = SearchEngine(
+ id = UUID.randomUUID().toString(),
+ name = "Custom",
+ icon = mockk(),
+ type = SearchEngine.Type.CUSTOM,
+ resultUrls = listOf(
+ "https://www.startpage.com/sp/search?q={searchTerms}",
+ ),
+ )
+
+ assertFalse(searchEngine.isCustomEngine())
+ assertTrue(customSearchEngine.isCustomEngine())
+ }
+
+ @Test
+ fun `well known search engines are identified correctly`() {
+ val searchEngine = SearchEngine(
+ id = UUID.randomUUID().toString(),
+ name = "Not well known",
+ icon = mockk(),
+ type = SearchEngine.Type.BUNDLED,
+ resultUrls = listOf(
+ "https://www.random.com/sp/search?q={searchTerms}",
+ ),
+ )
+
+ val wellKnownSearchEngine = SearchEngine(
+ id = UUID.randomUUID().toString(),
+ name = "Well known",
+ icon = mockk(),
+ type = SearchEngine.Type.CUSTOM,
+ resultUrls = listOf(
+ "https://www.startpage.com/sp/search?q={searchTerms}",
+ ),
+ )
+
+ assertFalse(searchEngine.isKnownSearchDomain())
+ assertTrue(wellKnownSearchEngine.isKnownSearchDomain())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/SharedPreferences.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/SharedPreferences.kt
new file mode 100644
index 0000000000..5cfd7bfd9c
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/SharedPreferences.kt
@@ -0,0 +1,12 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.ext
+
+import android.content.SharedPreferences
+
+/**
+ * Clear everything in shared preferences and commit changes immediately.
+ */
+fun SharedPreferences.clearAndCommit() = this.edit().clear().commit()
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/StringTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/StringTest.kt
new file mode 100644
index 0000000000..948a211bdd
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/StringTest.kt
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.ext
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class StringTest {
+
+ @Test
+ fun `Simplified Url`() {
+ val urlTest = "https://www.amazon.com"
+ val new = urlTest.simplifiedUrl()
+ assertEquals(new, "amazon.com")
+ }
+
+ @Test
+ fun testReplaceConsecutiveZeros() {
+ assertEquals(
+ "2001:db8::ff00:42:8329",
+ "2001:db8:0:0:0:ff00:42:8329".replaceConsecutiveZeros(),
+ )
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/TabCollectionTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/TabCollectionTest.kt
new file mode 100644
index 0000000000..df80d2681a
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/TabCollectionTest.kt
@@ -0,0 +1,77 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.ext
+
+import androidx.core.content.ContextCompat
+import io.mockk.every
+import io.mockk.mockk
+import mozilla.components.feature.tab.collections.TabCollection
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class TabCollectionTest {
+
+ @Test
+ fun getIconColor() {
+ val color = mockTabCollection(100L).getIconColor(testContext)
+ // Color does not change
+ for (i in 0..99) {
+ assertEquals(color, mockTabCollection(100L).getIconColor(testContext))
+ }
+
+ // Returns a color for negative IDs
+ val defaultColor = ContextCompat.getColor(testContext, R.color.fx_mobile_icon_color_oncolor)
+ assertNotEquals(defaultColor, mockTabCollection(-123L).getIconColor(testContext))
+ }
+
+ @Test
+ fun `GIVEN list of collections WHEN default collection number is required THEN return next default number`() {
+ val collections = mutableListOf<TabCollection>(
+ mockk {
+ every { title } returns "Collection 1"
+ },
+ mockk {
+ every { title } returns "Collection 2"
+ },
+ mockk {
+ every { title } returns "Collection 3"
+ },
+ )
+ assertEquals(4, collections.getDefaultCollectionNumber())
+
+ collections.add(
+ mockk {
+ every { title } returns "Collection 5"
+ },
+ )
+ assertEquals(6, collections.getDefaultCollectionNumber())
+
+ collections.add(
+ mockk {
+ every { title } returns "Random name"
+ },
+ )
+ assertEquals(6, collections.getDefaultCollectionNumber())
+
+ collections.add(
+ mockk {
+ every { title } returns "Collection 10 10"
+ },
+ )
+ assertEquals(6, collections.getDefaultCollectionNumber())
+ }
+
+ private fun mockTabCollection(id: Long): TabCollection {
+ val collection: TabCollection = mockk()
+ every { collection.id } returns id
+ return collection
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/TopSiteTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/TopSiteTest.kt
new file mode 100644
index 0000000000..1d312980c4
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/TopSiteTest.kt
@@ -0,0 +1,114 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.ext
+
+import mozilla.components.feature.top.sites.TopSite
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.mozilla.fenix.settings.SupportUtils
+
+class TopSiteTest {
+
+ val defaultGoogleTopSite = TopSite.Default(
+ id = 1L,
+ title = "Google",
+ url = SupportUtils.GOOGLE_URL,
+ createdAt = 0,
+ )
+ val providedSite1 = TopSite.Provided(
+ id = 3,
+ title = "Mozilla",
+ url = "https://mozilla.com",
+ clickUrl = "https://mozilla.com/click",
+ imageUrl = "https://test.com/image2.jpg",
+ impressionUrl = "https://example.com",
+ createdAt = 3,
+ )
+ val providedSite2 = TopSite.Provided(
+ id = 3,
+ title = "Firefox",
+ url = "https://firefox.com",
+ clickUrl = "https://firefox.com/click",
+ imageUrl = "https://test.com/image2.jpg",
+ impressionUrl = "https://example.com",
+ createdAt = 3,
+ )
+ val pinnedSite1 = TopSite.Pinned(
+ id = 1L,
+ title = "DuckDuckGo",
+ url = "https://duckduckgo.com",
+ createdAt = 0,
+ )
+ val pinnedSite2 = TopSite.Pinned(
+ id = 1L,
+ title = "Mozilla",
+ url = "mozilla.org",
+ createdAt = 0,
+ )
+ val frecentSite = TopSite.Frecent(
+ id = 1L,
+ title = "Mozilla",
+ url = "mozilla.org",
+ createdAt = 0,
+ )
+
+ @Test
+ fun `GIVEN the default Google top site is the first item WHEN the list of top sites is sorted THEN the order doesn't change`() {
+ val topSites = listOf(
+ defaultGoogleTopSite,
+ providedSite1,
+ providedSite2,
+ pinnedSite1,
+ pinnedSite2,
+ frecentSite,
+ )
+
+ assertEquals(topSites.sort(), topSites)
+ }
+
+ @Test
+ fun `GIVEN the default Google top site is after the provided top sites WHEN the list of top sites is sorted THEN the default Google top site should be first`() {
+ val topSites = listOf(
+ providedSite1,
+ providedSite2,
+ defaultGoogleTopSite,
+ pinnedSite1,
+ pinnedSite2,
+ frecentSite,
+ )
+ val expected = listOf(
+ defaultGoogleTopSite,
+ providedSite1,
+ providedSite2,
+ pinnedSite1,
+ pinnedSite2,
+ frecentSite,
+ )
+
+ assertEquals(topSites.sort(), expected)
+ }
+
+ @Test
+ fun `GIVEN the default Google top site is the last item WHEN the list of top sites is sorted THEN the default Google top site should be first`() {
+ val topSites = listOf(
+ providedSite1,
+ providedSite2,
+ pinnedSite1,
+ pinnedSite2,
+ frecentSite,
+ defaultGoogleTopSite,
+ )
+ val expected = listOf(
+ defaultGoogleTopSite,
+ providedSite1,
+ providedSite2,
+ pinnedSite1,
+ pinnedSite2,
+ frecentSite,
+ )
+
+ assertEquals(topSites.sort(), expected)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/UriTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/UriTest.kt
new file mode 100644
index 0000000000..ae5ebf4d01
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/UriTest.kt
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.ext
+
+import android.net.Uri
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.SupportUtils
+
+@RunWith(FenixRobolectricTestRunner::class)
+class UriTest {
+ @Test
+ fun `WHEN urlContainsQueryParameters is invoked THEN the result should be true only if the url contains the search parameters`() {
+ var searchParameters = ""
+ val googleSite = Uri.parse(SupportUtils.GOOGLE_URL)
+ val querySite = Uri.parse("test.com/?q=value")
+ val blankQuerySite = Uri.parse("test.com/?q=")
+
+ assertFalse(googleSite.containsQueryParameters(searchParameters))
+ assertFalse(querySite.containsQueryParameters(searchParameters))
+ assertFalse(blankQuerySite.containsQueryParameters(searchParameters))
+
+ searchParameters = "q"
+
+ assertFalse(googleSite.containsQueryParameters(searchParameters))
+ assertFalse(querySite.containsQueryParameters(searchParameters))
+ assertTrue(blankQuerySite.containsQueryParameters(searchParameters))
+
+ searchParameters = "q="
+
+ assertFalse(googleSite.containsQueryParameters(searchParameters))
+ assertFalse(querySite.containsQueryParameters(searchParameters))
+ assertTrue(blankQuerySite.containsQueryParameters(searchParameters))
+
+ searchParameters = "q=value"
+
+ assertFalse(googleSite.containsQueryParameters(searchParameters))
+ assertTrue(querySite.containsQueryParameters(searchParameters))
+ assertFalse(blankQuerySite.containsQueryParameters(searchParameters))
+ }
+
+ @Test
+ fun `WHEN an opaque url is checked for query parameters THEN then the result should be false`() {
+ val searchParameters = "q"
+ val opaqueUrl = Uri.parse("about:config")
+ val mailToUrl = Uri.parse("mailto:a@b.com")
+
+ assertFalse(opaqueUrl.containsQueryParameters(searchParameters))
+ assertFalse(mailToUrl.containsQueryParameters(searchParameters))
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ViewTest.kt
new file mode 100644
index 0000000000..a0a8df86ac
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/ext/ViewTest.kt
@@ -0,0 +1,180 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.ext
+
+import android.graphics.Rect
+import android.os.Build
+import android.util.DisplayMetrics
+import android.view.View
+import android.view.WindowInsets
+import android.widget.FrameLayout
+import androidx.core.view.WindowInsetsCompat
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.slot
+import io.mockk.verify
+import mozilla.components.support.ktx.android.util.dpToPx
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.utils.ext.bottom
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(FenixRobolectricTestRunner::class)
+class ViewTest {
+
+ @MockK private lateinit var view: View
+
+ @MockK private lateinit var parent: FrameLayout
+
+ @MockK private lateinit var displayMetrics: DisplayMetrics
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ mockkStatic("mozilla.components.support.ktx.android.util.DisplayMetricsKt")
+ mockkStatic("mozilla.components.support.utils.ext.WindowInsetsCompatKt")
+ mockkStatic("org.mozilla.fenix.ext.ViewKt")
+
+ every { view.context } answers { testContext }
+ every { view.resources.getDimensionPixelSize(any()) } answers {
+ testContext.resources.getDimensionPixelSize(firstArg())
+ }
+ every { view.resources.displayMetrics } returns displayMetrics
+ every { view.parent } returns parent
+ every { parent.touchDelegate = any() } just Runs
+ every { parent.post(any()) } answers {
+ // Immediately run the given Runnable argument
+ val action: Runnable = firstArg()
+ action.run()
+ true
+ }
+ }
+
+ @Test
+ fun `test increase touch area`() {
+ val hitRect = Rect(30, 40, 50, 60)
+ val dp = 10
+ val px = 20
+ val outRect = slot<Rect>()
+ every { dp.dpToPx(displayMetrics) } returns px
+ every { view.getHitRect(capture(outRect)) } answers { outRect.captured.set(hitRect) }
+
+ view.increaseTapArea(dp)
+ val expected = Rect(10, 20, 70, 80)
+ assertEquals(expected.left, outRect.captured.left)
+ assertEquals(expected.top, outRect.captured.top)
+ assertEquals(expected.right, outRect.captured.right)
+ assertEquals(expected.bottom, outRect.captured.bottom)
+ verify { parent.touchDelegate = any() }
+ }
+
+ @Test
+ fun `test remove touch delegate`() {
+ view.removeTouchDelegate()
+ verify { parent.touchDelegate = null }
+ }
+
+ @Config(sdk = [Build.VERSION_CODES.LOLLIPOP, Build.VERSION_CODES.LOLLIPOP_MR1])
+ @Test
+ fun `getWindowInsets returns null below API 23`() {
+ assertEquals(null, view.getWindowInsets())
+ }
+
+ @Test
+ fun `getWindowInsets returns null when the system insets don't exist`() {
+ every { view.rootWindowInsets } returns null
+ assertEquals(null, view.getWindowInsets())
+ }
+
+ @Test
+ fun `getWindowInsets returns the compat insets when the system insets exist`() {
+ val rootInsets: WindowInsets = mockk(relaxed = true)
+ every { view.rootWindowInsets } returns rootInsets
+
+ assertEquals(WindowInsetsCompat.toWindowInsetsCompat(rootInsets), view.getWindowInsets())
+ }
+
+ @Config(sdk = [Build.VERSION_CODES.LOLLIPOP, Build.VERSION_CODES.LOLLIPOP_MR1])
+ @Test
+ fun `getKeyboardHeight accounts for status bar below API 23`() {
+ every { view.getWindowVisibleDisplayFrame() } returns Rect(0, 50, 1000, 500)
+ every { view.rootView.height } returns 1000
+
+ assertEquals(500, view.getKeyboardHeight())
+ }
+
+ @Test
+ fun `getKeyboardHeight accounts for status bar and navigation bar`() {
+ val windowInsetsCompat: WindowInsetsCompat = mockk()
+
+ every { view.getWindowVisibleDisplayFrame() } returns Rect(0, 50, 1000, 500)
+ every { view.rootView.height } returns 1000
+ every { view.getWindowInsets() } returns windowInsetsCompat
+ every { windowInsetsCompat.bottom() } returns 50
+
+ assertEquals(450, view.getKeyboardHeight())
+ }
+
+ @Config(sdk = [Build.VERSION_CODES.LOLLIPOP, Build.VERSION_CODES.LOLLIPOP_MR1])
+ @Test
+ fun `isKeyboardVisible returns false when the keyboard height is less than or equal to the minimum threshold`() {
+ val threshold = testContext.resources.getDimensionPixelSize(R.dimen.minimum_keyboard_height)
+
+ every { view.getKeyboardHeight() } returns threshold - 1
+ assertEquals(false, view.isKeyboardVisible())
+
+ every { view.getKeyboardHeight() } returns threshold
+ assertEquals(false, view.isKeyboardVisible())
+ }
+
+ @Config(sdk = [Build.VERSION_CODES.LOLLIPOP, Build.VERSION_CODES.LOLLIPOP_MR1])
+ @Test
+ fun `isKeyboardVisible returns true when the keyboard height is greater than the minimum threshold`() {
+ val threshold = testContext.resources.getDimensionPixelSize(R.dimen.minimum_keyboard_height)
+ every { view.getKeyboardHeight() } returns threshold + 1
+
+ assertEquals(true, view.isKeyboardVisible())
+ }
+
+ @Test
+ fun `isKeyboardVisible returns false when the keyboard height is 0`() {
+ every { view.getKeyboardHeight() } returns 0
+ assertEquals(false, view.isKeyboardVisible())
+ }
+
+ @Test
+ fun `isKeyboardVisible returns true when the keyboard height is greater than 0`() {
+ every { view.getKeyboardHeight() } returns 100
+ assertEquals(true, view.isKeyboardVisible())
+ }
+
+ @Test
+ fun `getRectWithScreenLocation should transform getLocationInScreen method values`() {
+ val locationOnScreen = slot<IntArray>()
+ every { view.getLocationOnScreen(capture(locationOnScreen)) } answers {
+ locationOnScreen.captured[0] = 100
+ locationOnScreen.captured[1] = 200
+ }
+ every { view.width } returns 150
+ every { view.height } returns 250
+
+ val outRect = view.getRectWithScreenLocation()
+
+ assertEquals(100, outRect.left)
+ assertEquals(200, outRect.top)
+ assertEquals(250, outRect.right)
+ assertEquals(450, outRect.bottom)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/extension/WebExtensionPromptFeatureTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/extension/WebExtensionPromptFeatureTest.kt
new file mode 100644
index 0000000000..f284015008
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/extension/WebExtensionPromptFeatureTest.kt
@@ -0,0 +1,328 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.extension
+
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.runs
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.browser.state.action.WebExtensionAction.UpdatePromptRequestWebExtensionAction
+import mozilla.components.browser.state.state.extension.WebExtensionPromptRequest
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.webextension.WebExtensionInstallException
+import mozilla.components.feature.addons.Addon
+import mozilla.components.support.ktx.android.content.appVersionName
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class WebExtensionPromptFeatureTest {
+
+ private lateinit var webExtensionPromptFeature: WebExtensionPromptFeature
+ private lateinit var store: BrowserStore
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Before
+ fun setup() {
+ store = BrowserStore()
+ webExtensionPromptFeature = spyk(
+ WebExtensionPromptFeature(
+ store = store,
+ context = testContext,
+ fragmentManager = mockk(relaxed = true),
+ addonManager = mockk(relaxed = true),
+ ),
+ )
+ }
+
+ @Test
+ fun `WHEN InstallationFailed is dispatched THEN handleInstallationFailedRequest is called`() {
+ webExtensionPromptFeature.start()
+
+ every { webExtensionPromptFeature.handleInstallationFailedRequest(any()) } just runs
+
+ store.dispatch(
+ UpdatePromptRequestWebExtensionAction(
+ WebExtensionPromptRequest.BeforeInstallation.InstallationFailed(
+ mockk(),
+ mockk(),
+ ),
+ ),
+ ).joinBlocking()
+
+ verify { webExtensionPromptFeature.handleInstallationFailedRequest(any()) }
+ }
+
+ @Test
+ fun `WHEN calling handleInstallationFailedRequest with network error THEN showDialog with the correct message`() {
+ val expectedTitle =
+ testContext.getString(R.string.mozac_feature_addons_failed_to_install, "")
+ val exception = WebExtensionInstallException.NetworkFailure(
+ extensionName = "name",
+ throwable = Exception(),
+ )
+ val expectedMessage =
+ testContext.getString(
+ R.string.mozac_feature_addons_extension_failed_to_install_network_error,
+ "name",
+ )
+
+ webExtensionPromptFeature.handleInstallationFailedRequest(
+ exception = exception,
+ )
+
+ verify { webExtensionPromptFeature.showDialog(expectedTitle, expectedMessage) }
+ }
+
+ @Test
+ fun `WHEN calling handleInstallationFailedRequest with Blocklisted error THEN showDialog with the correct message`() {
+ val expectedTitle =
+ testContext.getString(R.string.mozac_feature_addons_failed_to_install, "")
+ val extensionName = "extensionName"
+ val exception = WebExtensionInstallException.Blocklisted(
+ extensionName = extensionName,
+ throwable = Exception(),
+ )
+ val expectedMessage =
+ testContext.getString(R.string.mozac_feature_addons_blocklisted_1, extensionName)
+
+ webExtensionPromptFeature.handleInstallationFailedRequest(
+ exception = exception,
+ )
+
+ verify { webExtensionPromptFeature.showDialog(expectedTitle, expectedMessage) }
+ }
+
+ @Test
+ fun `WHEN calling handleInstallationFailedRequest with UserCancelled error THEN do not showDialog`() {
+ val expectedTitle = ""
+ val extensionName = "extensionName"
+ val exception = WebExtensionInstallException.UserCancelled(
+ extensionName = extensionName,
+ throwable = Exception(),
+ )
+ val expectedMessage =
+ testContext.getString(R.string.mozac_feature_addons_failed_to_install, extensionName)
+
+ webExtensionPromptFeature.handleInstallationFailedRequest(
+ exception = exception,
+ )
+
+ verify(exactly = 0) { webExtensionPromptFeature.showDialog(expectedTitle, expectedMessage) }
+ }
+
+ @Test
+ fun `WHEN calling handleInstallationFailedRequest with Unknown error THEN showDialog with the correct message`() {
+ val expectedTitle = ""
+ val extensionName = "extensionName"
+ val exception = WebExtensionInstallException.Unknown(
+ extensionName = extensionName,
+ throwable = Exception(),
+ )
+ val expectedMessage =
+ testContext.getString(R.string.mozac_feature_addons_failed_to_install, extensionName)
+
+ webExtensionPromptFeature.handleInstallationFailedRequest(
+ exception = exception,
+ )
+
+ verify { webExtensionPromptFeature.showDialog(expectedTitle, expectedMessage) }
+ }
+
+ @Test
+ fun `WHEN calling handleInstallationFailedRequest with Unknown error and no extension name THEN showDialog with the correct message`() {
+ val expectedTitle = ""
+ val exception = WebExtensionInstallException.Unknown(
+ extensionName = null,
+ throwable = Exception(),
+ )
+ val expectedMessage =
+ testContext.getString(R.string.mozac_feature_addons_extension_failed_to_install)
+
+ webExtensionPromptFeature.handleInstallationFailedRequest(
+ exception = exception,
+ )
+
+ verify { webExtensionPromptFeature.showDialog(expectedTitle, expectedMessage) }
+ }
+
+ @Test
+ fun `WHEN calling handleInstallationFailedRequest with CorruptFile error THEN showDialog with the correct message`() {
+ val expectedTitle =
+ testContext.getString(R.string.mozac_feature_addons_failed_to_install, "")
+ val exception = WebExtensionInstallException.CorruptFile(
+ throwable = Exception(),
+ )
+ val expectedMessage =
+ testContext.getString(R.string.mozac_feature_addons_extension_failed_to_install_corrupt_error)
+
+ webExtensionPromptFeature.handleInstallationFailedRequest(
+ exception = exception,
+ )
+
+ verify { webExtensionPromptFeature.showDialog(expectedTitle, expectedMessage) }
+ }
+
+ @Test
+ fun `WHEN calling handleInstallationFailedRequest with NotSigned error THEN showDialog with the correct message`() {
+ val expectedTitle =
+ testContext.getString(R.string.mozac_feature_addons_failed_to_install, "")
+ val exception = WebExtensionInstallException.NotSigned(
+ throwable = Exception(),
+ )
+ val expectedMessage =
+ testContext.getString(R.string.mozac_feature_addons_extension_failed_to_install_not_signed_error)
+
+ webExtensionPromptFeature.handleInstallationFailedRequest(
+ exception = exception,
+ )
+
+ verify { webExtensionPromptFeature.showDialog(expectedTitle, expectedMessage) }
+ }
+
+ @Test
+ fun `WHEN calling handleInstallationFailedRequest with Incompatible error THEN showDialog with the correct message`() {
+ val expectedTitle =
+ testContext.getString(R.string.mozac_feature_addons_failed_to_install, "")
+ val extensionName = "extensionName"
+ val exception = WebExtensionInstallException.Incompatible(
+ extensionName = extensionName,
+ throwable = Exception(),
+ )
+ val appName = testContext.getString(R.string.app_name)
+ val version = testContext.appVersionName
+ val expectedMessage =
+ testContext.getString(
+ R.string.mozac_feature_addons_failed_to_install_incompatible_error,
+ extensionName,
+ appName,
+ version,
+ )
+
+ webExtensionPromptFeature.handleInstallationFailedRequest(
+ exception = exception,
+ )
+
+ verify { webExtensionPromptFeature.showDialog(expectedTitle, expectedMessage) }
+ }
+
+ @Test
+ fun `WHEN AfterInstallation is dispatched THEN handleAfterInstallationRequest is called`() {
+ webExtensionPromptFeature.start()
+
+ every { webExtensionPromptFeature.handleAfterInstallationRequest(any()) } returns mockk()
+
+ store.dispatch(
+ UpdatePromptRequestWebExtensionAction(
+ WebExtensionPromptRequest.AfterInstallation.Permissions.Optional(
+ mockk(relaxed = true),
+ mockk(),
+ mockk(),
+ ),
+ ),
+ ).joinBlocking()
+
+ verify { webExtensionPromptFeature.handleAfterInstallationRequest(any()) }
+ }
+
+ @Test
+ fun `GIVEN Optional Permissions WHEN handleAfterInstallationRequest is called THEN handleOptionalPermissionsRequest is called`() {
+ webExtensionPromptFeature.start()
+ val request = mockk<WebExtensionPromptRequest.AfterInstallation.Permissions.Optional>(relaxed = true)
+
+ webExtensionPromptFeature.handleAfterInstallationRequest(request)
+
+ verify { webExtensionPromptFeature.handleOptionalPermissionsRequest(any(), any()) }
+ }
+
+ @Test
+ fun `WHEN calling handleOptionalPermissionsRequest with permissions THEN call showPermissionDialog`() {
+ val addon: Addon = mockk(relaxed = true)
+ val promptRequest = WebExtensionPromptRequest.AfterInstallation.Permissions.Optional(
+ extension = mockk(),
+ permissions = listOf("tabs"),
+ onConfirm = mockk(),
+ )
+
+ webExtensionPromptFeature.handleOptionalPermissionsRequest(addon = addon, promptRequest = promptRequest)
+
+ verify {
+ webExtensionPromptFeature.showPermissionDialog(
+ eq(addon),
+ eq(promptRequest),
+ eq(true),
+ eq(promptRequest.permissions),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN calling handleOptionalPermissionsRequest with a permission that doesn't have a description THEN do not call showPermissionDialog`() {
+ val addon: Addon = mockk(relaxed = true)
+ val onConfirm: ((Boolean) -> Unit) = mockk()
+ every { onConfirm(any()) } just runs
+ val promptRequest = WebExtensionPromptRequest.AfterInstallation.Permissions.Optional(
+ extension = mockk(),
+ // The "scripting" API permission doesn't have a description so we should not show a dialog for it.
+ permissions = listOf("scripting"),
+ onConfirm = onConfirm,
+ )
+
+ webExtensionPromptFeature.handleOptionalPermissionsRequest(addon = addon, promptRequest = promptRequest)
+
+ verify(exactly = 0) {
+ webExtensionPromptFeature.showPermissionDialog(any(), any(), any(), any())
+ }
+ verify(exactly = 1) { onConfirm(true) }
+ }
+
+ @Test
+ fun `WHEN calling handleOptionalPermissionsRequest with no permissions THEN do not call showPermissionDialog`() {
+ val addon: Addon = mockk(relaxed = true)
+ val onConfirm: ((Boolean) -> Unit) = mockk()
+ every { onConfirm(any()) } just runs
+ val promptRequest = WebExtensionPromptRequest.AfterInstallation.Permissions.Optional(
+ extension = mockk(),
+ permissions = emptyList(),
+ onConfirm = onConfirm,
+ )
+
+ webExtensionPromptFeature.handleOptionalPermissionsRequest(addon = addon, promptRequest = promptRequest)
+
+ verify(exactly = 0) {
+ webExtensionPromptFeature.showPermissionDialog(any(), any(), any(), any())
+ }
+ verify(exactly = 1) { onConfirm(true) }
+ }
+
+ @Test
+ fun `WHEN calling handleInstallationFailedRequest with UnsupportedAddonType error THEN showDialog with the correct message`() {
+ val expectedTitle = ""
+ val extensionName = "extensionName"
+ val exception = WebExtensionInstallException.UnsupportedAddonType(
+ extensionName = extensionName,
+ throwable = Exception(),
+ )
+ val expectedMessage =
+ testContext.getString(R.string.mozac_feature_addons_failed_to_install, extensionName)
+
+ webExtensionPromptFeature.handleInstallationFailedRequest(
+ exception = exception,
+ )
+
+ verify { webExtensionPromptFeature.showDialog(expectedTitle, expectedMessage) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/helpers/FenixRobolectricTestApplication.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/helpers/FenixRobolectricTestApplication.kt
new file mode 100644
index 0000000000..fa16108bef
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/helpers/FenixRobolectricTestApplication.kt
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.helpers
+
+import io.mockk.mockk
+import org.mozilla.fenix.FenixApplication
+import org.mozilla.fenix.R
+import org.mozilla.fenix.components.Components
+
+/**
+ * An override of our application for use in Robolectric-based unit tests. We're forced to override
+ * because our standard application fails to initialize in Robolectric with exceptions like:
+ * "Crash handler service must run in a separate process".
+ */
+class FenixRobolectricTestApplication : FenixApplication() {
+
+ override fun onCreate() {
+ super.onCreate()
+ setApplicationTheme()
+ }
+
+ override val components = mockk<Components>()
+
+ override fun initializeNimbus() = Unit
+
+ override fun initializeGlean() = Unit
+
+ override fun setupInAllProcesses() = Unit
+
+ override fun setupInMainProcessOnly() = Unit
+
+ override fun downloadWallpapers() = Unit
+
+ private fun setApplicationTheme() {
+ // According to the Robolectric devs, the application context will not have the <application>'s
+ // theme but will use the platform's default team so we set our theme here. We change it here
+ // rather than the production application because, upon testing, the production code appears
+ // appears to be working correctly. Context here:
+ // https://github.com/mozilla-mobile/fenix/pull/15646#issuecomment-707345798
+ // https://github.com/mozilla-mobile/fenix/pull/15646#issuecomment-709411141
+ setTheme(R.style.NormalTheme)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/helpers/FenixRobolectricTestRunner.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/helpers/FenixRobolectricTestRunner.kt
new file mode 100644
index 0000000000..37f5f47574
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/helpers/FenixRobolectricTestRunner.kt
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.helpers
+
+import org.robolectric.RobolectricTestRunner
+
+/**
+ * A test runner that was added to start Robolectric with our custom configuration for use in unit tests.
+ *
+ * This class is now deprecated as the configuration is set by robolectric.properties instead.
+ * You should only use Robolectric when necessary because it non-trivially increases test duration.
+ *
+ * usage:
+ * ```
+ * @RunWith(FenixRobolectricTestRunner::class)
+ * class ExampleUnitTest {
+ * ```
+ *
+ * There were three common test runners before this patch:
+ * 1. The default (@RunWith not specified) = JUnit4
+ * 2. @RunWith(RobolectricTestRunner::class) = JUnit4 with support for the Android framework via Robolectric
+ * 3. @RunWith(AndroidJUnit4::class) = JUnit4 with support for the Android framework. This currently
+ * delegates to Robolectric but is presumably generically named so that it can support different
+ * implementations in the future. The name creates confusion on over the difference between this and
+ * JUnit without any Android support (1).
+ *
+ * We chose the name RobolectricTestRunner because we want folks to know they're starting Robolectric
+ * because it increases test runtime. Furthermore, the naming of 3) is unclear so we didn't want to
+ * use that name.
+ *
+ * As a result, new tests should use RobolectricTestRunner.
+ */
+class FenixRobolectricTestRunner(testClass: Class<*>) : RobolectricTestRunner(testClass)
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/helpers/LocaleTestRule.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/helpers/LocaleTestRule.kt
new file mode 100644
index 0000000000..1a917cea22
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/helpers/LocaleTestRule.kt
@@ -0,0 +1,37 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.helpers
+
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+import java.util.Locale
+
+/**
+ * A JUnit [TestRule] that sets the default locale to a given [localeToSet] for the duration of
+ * the test and then resets it to the original locale.
+ *
+ * @param localeToSet The locale to set for the duration of the test.
+ */
+class LocaleTestRule(private val localeToSet: Locale) : TestRule {
+
+ private var originalLocale: Locale? = null
+
+ override fun apply(base: Statement, description: Description): Statement =
+ object : Statement() {
+
+ override fun evaluate() {
+ originalLocale = Locale.getDefault()
+ Locale.setDefault(localeToSet)
+
+ try {
+ base.evaluate() // Run the tests
+ } finally {
+ // Reset to the original locale after tests
+ Locale.setDefault(originalLocale!!)
+ }
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/helpers/MockkRetryTestRule.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/helpers/MockkRetryTestRule.kt
new file mode 100644
index 0000000000..5bce905ded
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/helpers/MockkRetryTestRule.kt
@@ -0,0 +1,65 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.helpers
+
+import io.mockk.MockKException
+import io.mockk.unmockkAll
+import mozilla.components.support.base.log.logger.Logger
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+
+/**
+ * [TestRule] to work around mockk problem that causes intermittent failures
+ * of tests with mocked lambdas. This rule will call `unmockAll` and retry
+ * running the failing test until [maxTries] is reached.
+ *
+ * See:
+ * https://github.com/mockk/mockk/issues/598
+ * https://github.com/mozilla-mobile/fenix/issues/21952
+ * https://github.com/mozilla-mobile/fenix/issues/22240
+ */
+class MockkRetryTestRule(val maxTries: Int = 3) : TestRule {
+
+ private val logger = Logger("MockkRetryTestRule")
+
+ @Suppress("TooGenericExceptionCaught", "NestedBlockDepth")
+ override fun apply(base: Statement, description: Description): Statement {
+ return object : Statement() {
+ override fun evaluate() {
+ var failure: Throwable? = null
+
+ for (i in 0 until maxTries) {
+ try {
+ base.evaluate()
+ return
+ } catch (throwable: Throwable) {
+ when (throwable) {
+ // Work around intermittently failing tests with mocked lambdas
+ // on JDK 11: https://github.com/mockk/mockk/issues/598
+ is InstantiationError,
+ is MockKException,
+ -> {
+ failure = throwable
+ val message = if (i < maxTries - 1) {
+ "Retrying test \"${description.displayName}\""
+ } else {
+ "Giving up on test \"${description.displayName}\" after $maxTries tries"
+ }
+ logger.error(message, throwable)
+ unmockkAll()
+ }
+ else -> {
+ throw throwable
+ }
+ }
+ }
+ }
+
+ throw failure!!
+ }
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/helpers/StackTraces.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/helpers/StackTraces.kt
new file mode 100644
index 0000000000..19fc97f62b
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/helpers/StackTraces.kt
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.helpers
+
+/**
+ * A collection of test helper functions for manipulating stack traces.
+ */
+object StackTraces {
+
+ /**
+ * Gets a stack trace from logcat output. To use this, you should remove the name of the
+ * Exception or "Caused by" lines causing the problem and only use the stack trace lines below
+ * it. See src/test/resources/EdmStorageProviderBaseLogcat.txt for an example.
+ */
+ fun getStackTraceFromLogcat(logcatResourcePath: String): Array<StackTraceElement> {
+ val logcat = javaClass.classLoader!!.getResource(logcatResourcePath).readText()
+ val lines = logcat.split('\n').filter(String::isNotBlank)
+ return lines.map(::logcatLineToStackTraceElement).toTypedArray()
+ }
+
+ private fun logcatLineToStackTraceElement(line: String): StackTraceElement {
+ // Expected format:
+ // 02-08 10:56:02.185 21990 21990 E AndroidRuntime: at android.os.StrictMode$AndroidBlockGuardPolicy.onReadFromDisk(StrictMode.java:1556)
+
+ // Expected: android.os.StrictMode$AndroidBlockGuardPolicy.onReadFromDisk
+ val methodInfo = line.substringBefore('(').substringAfterLast(' ')
+ val methodName = methodInfo.substringAfterLast('.')
+ val declaringClass = methodInfo.substringBeforeLast('.')
+
+ // Expected: StrictMode.java:1556
+ val fileInfo = line.substringAfter('(').substringBefore(')')
+ val fileName = fileInfo.substringBefore(':')
+ val lineNumber = fileInfo.substringAfter(':').toInt()
+
+ return StackTraceElement(declaringClass, methodName, fileName, lineNumber)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/helpers/perf/TestStrictModeManager.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/helpers/perf/TestStrictModeManager.kt
new file mode 100644
index 0000000000..b2a3aada79
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/helpers/perf/TestStrictModeManager.kt
@@ -0,0 +1,22 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.helpers.perf
+
+import android.os.StrictMode
+import io.mockk.mockk
+import org.mozilla.fenix.perf.StrictModeManager
+
+/**
+ * A test version of [StrictModeManager]. This class is difficult to mock because of [resetAfter]
+ * so we provide a test implementation.
+ */
+class TestStrictModeManager : StrictModeManager(mockk(relaxed = true), mockk(relaxed = true)) {
+
+ // This method is hard to mock because this method needs to return the return value of the
+ // function passed in.
+ override fun <R> resetAfter(policy: StrictMode.ThreadPolicy, functionBlock: () -> R): R {
+ return functionBlock()
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/DefaultSessionControlControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/DefaultSessionControlControllerTest.kt
new file mode 100644
index 0000000000..9061bd669d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/DefaultSessionControlControllerTest.kt
@@ -0,0 +1,1392 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home
+
+import androidx.navigation.NavController
+import androidx.navigation.NavDirections
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.spyk
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import mozilla.components.browser.state.action.SearchAction
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.search.RegionState
+import mozilla.components.browser.state.search.SearchEngine
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.ReaderState
+import mozilla.components.browser.state.state.SearchState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.state.recover.RecoverableTab
+import mozilla.components.browser.state.state.recover.TabState
+import mozilla.components.browser.state.state.selectedOrDefaultSearchEngine
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.Engine
+import mozilla.components.feature.session.SessionUseCases
+import mozilla.components.feature.tab.collections.TabCollection
+import mozilla.components.feature.tabs.TabsUseCases
+import mozilla.components.feature.top.sites.TopSite
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.service.nimbus.messaging.Message
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.BrowserDirection
+import org.mozilla.fenix.GleanMetrics.Collections
+import org.mozilla.fenix.GleanMetrics.Events
+import org.mozilla.fenix.GleanMetrics.HomeScreen
+import org.mozilla.fenix.GleanMetrics.Pings
+import org.mozilla.fenix.GleanMetrics.RecentBookmarks
+import org.mozilla.fenix.GleanMetrics.RecentTabs
+import org.mozilla.fenix.GleanMetrics.TopSites
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.R
+import org.mozilla.fenix.browser.browsingmode.BrowsingMode
+import org.mozilla.fenix.components.Analytics
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.TabCollectionStorage
+import org.mozilla.fenix.components.appstate.AppAction
+import org.mozilla.fenix.components.appstate.AppState
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.home.recentbookmarks.RecentBookmark
+import org.mozilla.fenix.home.recenttabs.RecentTab
+import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController
+import org.mozilla.fenix.messaging.MessageController
+import org.mozilla.fenix.onboarding.WallpaperOnboardingDialogFragment.Companion.THUMBNAILS_SELECTION_COUNT
+import org.mozilla.fenix.settings.SupportUtils
+import org.mozilla.fenix.utils.Settings
+import org.mozilla.fenix.wallpapers.Wallpaper
+import org.mozilla.fenix.wallpapers.WallpaperState
+import java.io.File
+import mozilla.components.feature.tab.collections.Tab as ComponentTab
+
+@RunWith(FenixRobolectricTestRunner::class) // For gleanTestRule
+class DefaultSessionControlControllerTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ private val activity: HomeActivity = mockk(relaxed = true)
+ private val filesDir: File = mockk(relaxed = true)
+ private val appStore: AppStore = mockk(relaxed = true)
+ private val navController: NavController = mockk(relaxed = true)
+ private val messageController: MessageController = mockk(relaxed = true)
+ private val engine: Engine = mockk(relaxed = true)
+ private val tabCollectionStorage: TabCollectionStorage = mockk(relaxed = true)
+ private val tabsUseCases: TabsUseCases = mockk(relaxed = true)
+ private val reloadUrlUseCase: SessionUseCases = mockk(relaxed = true)
+ private val selectTabUseCase: TabsUseCases = mockk(relaxed = true)
+ private val settings: Settings = mockk(relaxed = true)
+ private val analytics: Analytics = mockk(relaxed = true)
+ private val scope = coroutinesTestRule.scope
+ private val searchEngine = SearchEngine(
+ id = "test",
+ name = "Test Engine",
+ icon = mockk(relaxed = true),
+ type = SearchEngine.Type.BUNDLED,
+ resultUrls = listOf("https://example.org/?q={searchTerms}"),
+ )
+
+ private val googleSearchEngine = SearchEngine(
+ id = "googleTest",
+ name = "Google Test Engine",
+ icon = mockk(relaxed = true),
+ type = SearchEngine.Type.BUNDLED,
+ resultUrls = listOf("https://www.google.com/?q={searchTerms}"),
+ suggestUrl = "https://www.google.com/",
+ )
+
+ private val duckDuckGoSearchEngine = SearchEngine(
+ id = "ddgTest",
+ name = "DuckDuckGo Test Engine",
+ icon = mockk(relaxed = true),
+ type = SearchEngine.Type.BUNDLED,
+ resultUrls = listOf("https://duckduckgo.com/?q=%7BsearchTerms%7D&t=fpas"),
+ suggestUrl = "https://ac.duckduckgo.com/ac/?q=%7BsearchTerms%7D&type=list",
+ )
+
+ private lateinit var store: BrowserStore
+ private val appState: AppState = mockk(relaxed = true)
+
+ @Before
+ fun setup() {
+ store = BrowserStore(
+ BrowserState(
+ search = SearchState(
+ regionSearchEngines = listOf(searchEngine),
+ ),
+ ),
+ )
+
+ every { appStore.state } returns AppState(
+ collections = emptyList(),
+ expandedCollections = emptySet(),
+ mode = BrowsingMode.Normal,
+ topSites = emptyList(),
+ showCollectionPlaceholder = true,
+ recentTabs = emptyList(),
+ recentBookmarks = emptyList(),
+ )
+
+ every { navController.currentDestination } returns mockk {
+ every { id } returns R.id.homeFragment
+ }
+ every { activity.components.settings } returns settings
+ every { activity.settings() } returns settings
+ every { activity.components.analytics } returns analytics
+ every { activity.filesDir } returns filesDir
+ every { filesDir.path } returns "/test"
+ }
+
+ @Test
+ fun handleCollectionAddTabTapped() {
+ val collection = mockk<TabCollection> {
+ every { id } returns 12L
+ }
+ createController().handleCollectionAddTabTapped(collection)
+
+ assertNotNull(Collections.addTabButton.testGetValue())
+ val recordedEvents = Collections.addTabButton.testGetValue()!!
+ assertEquals(1, recordedEvents.size)
+ assertEquals(null, recordedEvents.single().extra)
+
+ verify {
+ navController.navigate(
+ match<NavDirections> {
+ it.actionId == R.id.action_global_collectionCreationFragment
+ },
+ null,
+ )
+ }
+ }
+
+ @Test
+ fun handleCustomizeHomeTapped() {
+ assertNull(HomeScreen.customizeHomeClicked.testGetValue())
+
+ createController().handleCustomizeHomeTapped()
+
+ assertNotNull(HomeScreen.customizeHomeClicked.testGetValue())
+ verify {
+ navController.navigate(
+ match<NavDirections> {
+ it.actionId == R.id.action_global_homeSettingsFragment
+ },
+ null,
+ )
+ }
+ }
+
+ @Test
+ fun `handleCollectionOpenTabClicked onFailure`() {
+ val tab = mockk<ComponentTab> {
+ every { url } returns "https://mozilla.org"
+ every { restore(filesDir, engine, restoreSessionId = false) } returns null
+ }
+ createController().handleCollectionOpenTabClicked(tab)
+
+ assertNotNull(Collections.tabRestored.testGetValue())
+ val recordedEvents = Collections.tabRestored.testGetValue()!!
+ assertEquals(1, recordedEvents.size)
+ assertEquals(null, recordedEvents.single().extra)
+
+ verify {
+ activity.openToBrowserAndLoad(
+ searchTermOrURL = "https://mozilla.org",
+ newTab = true,
+ from = BrowserDirection.FromHome,
+ )
+ }
+ }
+
+ @Test
+ fun `handleCollectionOpenTabClicked with existing selected tab`() {
+ val recoverableTab = RecoverableTab(
+ engineSessionState = null,
+ state = TabState(
+ id = "test",
+ parentId = null,
+ url = "https://www.mozilla.org",
+ title = "Mozilla",
+ contextId = null,
+ readerState = ReaderState(),
+ lastAccess = 0,
+ private = false,
+ ),
+ )
+
+ val tab = mockk<ComponentTab> {
+ every { restore(filesDir, engine, restoreSessionId = false) } returns recoverableTab
+ }
+
+ val restoredTab = createTab(id = recoverableTab.state.id, url = recoverableTab.state.url)
+ val otherTab = createTab(id = "otherTab", url = "https://mozilla.org")
+ store.dispatch(TabListAction.AddTabAction(otherTab)).joinBlocking()
+ store.dispatch(TabListAction.SelectTabAction(otherTab.id)).joinBlocking()
+ store.dispatch(TabListAction.AddTabAction(restoredTab)).joinBlocking()
+
+ createController().handleCollectionOpenTabClicked(tab)
+
+ assertNotNull(Collections.tabRestored.testGetValue())
+ val recordedEvents = Collections.tabRestored.testGetValue()!!
+ assertEquals(1, recordedEvents.size)
+ assertEquals(null, recordedEvents.single().extra)
+
+ verify { activity.openToBrowser(BrowserDirection.FromHome) }
+ verify { selectTabUseCase.selectTab.invoke(restoredTab.id) }
+ verify { reloadUrlUseCase.reload.invoke(restoredTab.id) }
+ }
+
+ @Test
+ fun `handleCollectionOpenTabClicked without existing selected tab`() {
+ val recoverableTab = RecoverableTab(
+ engineSessionState = null,
+ state = TabState(
+ id = "test",
+ parentId = null,
+ url = "https://www.mozilla.org",
+ title = "Mozilla",
+ contextId = null,
+ readerState = ReaderState(),
+ lastAccess = 0,
+ private = false,
+ ),
+ )
+
+ val tab = mockk<ComponentTab> {
+ every { restore(filesDir, engine, restoreSessionId = false) } returns recoverableTab
+ }
+
+ val restoredTab = createTab(id = recoverableTab.state.id, url = recoverableTab.state.url)
+ store.dispatch(TabListAction.AddTabAction(restoredTab)).joinBlocking()
+
+ createController().handleCollectionOpenTabClicked(tab)
+
+ assertNotNull(Collections.tabRestored.testGetValue())
+ val recordedEvents = Collections.tabRestored.testGetValue()!!
+ assertEquals(1, recordedEvents.size)
+ assertEquals(null, recordedEvents.single().extra)
+
+ verify { activity.openToBrowser(BrowserDirection.FromHome) }
+ verify { selectTabUseCase.selectTab.invoke(restoredTab.id) }
+ verify { reloadUrlUseCase.reload.invoke(restoredTab.id) }
+ }
+
+ @Test
+ fun handleCollectionOpenTabsTapped() {
+ val collection = mockk<TabCollection> {
+ every { tabs } returns emptyList()
+ }
+ createController().handleCollectionOpenTabsTapped(collection)
+
+ assertNotNull(Collections.allTabsRestored.testGetValue())
+ val recordedEvents = Collections.allTabsRestored.testGetValue()!!
+ assertEquals(1, recordedEvents.size)
+ assertEquals(null, recordedEvents.single().extra)
+ }
+
+ @Test
+ fun `handleCollectionRemoveTab one tab`() {
+ val expectedCollection = mockk<TabCollection> {
+ every { tabs } returns listOf(mockk())
+ every { title } returns "Collection"
+ }
+ val tab = mockk<ComponentTab>()
+ every {
+ activity.resources.getString(
+ R.string.delete_tab_and_collection_dialog_title,
+ "Collection",
+ )
+ } returns "Delete Collection?"
+ every {
+ activity.resources.getString(R.string.delete_tab_and_collection_dialog_message)
+ } returns "Deleting this tab will delete everything."
+
+ var actualCollection: TabCollection? = null
+
+ createController(
+ removeCollectionWithUndo = { collection ->
+ actualCollection = collection
+ },
+ ).handleCollectionRemoveTab(expectedCollection, tab)
+
+ assertNotNull(Collections.tabRemoved.testGetValue())
+ val recordedEvents = Collections.tabRemoved.testGetValue()!!
+ assertEquals(1, recordedEvents.size)
+ assertEquals(null, recordedEvents.single().extra)
+
+ assertEquals(expectedCollection, actualCollection)
+ }
+
+ @Test
+ fun `handleCollectionRemoveTab multiple tabs`() {
+ val collection: TabCollection = mockk(relaxed = true)
+ val tab: ComponentTab = mockk(relaxed = true)
+ createController().handleCollectionRemoveTab(collection, tab)
+
+ assertNotNull(Collections.tabRemoved.testGetValue())
+ val recordedEvents = Collections.tabRemoved.testGetValue()!!
+ assertEquals(1, recordedEvents.size)
+ assertEquals(null, recordedEvents.single().extra)
+ }
+
+ @Test
+ fun handleCollectionShareTabsClicked() {
+ val collection = mockk<TabCollection> {
+ every { tabs } returns emptyList()
+ every { title } returns ""
+ }
+ createController().handleCollectionShareTabsClicked(collection)
+
+ assertNotNull(Collections.shared.testGetValue())
+ val recordedEvents = Collections.shared.testGetValue()!!
+ assertEquals(1, recordedEvents.size)
+ assertEquals(null, recordedEvents.single().extra)
+
+ verify {
+ navController.navigate(
+ match<NavDirections> { it.actionId == R.id.action_global_shareFragment },
+ null,
+ )
+ }
+ }
+
+ @Test
+ fun handleDeleteCollectionTapped() {
+ val expectedCollection = mockk<TabCollection> {
+ every { title } returns "Collection"
+ }
+ every {
+ activity.resources.getString(R.string.tab_collection_dialog_message, "Collection")
+ } returns "Are you sure you want to delete Collection?"
+
+ var actualCollection: TabCollection? = null
+
+ createController(
+ removeCollectionWithUndo = { collection ->
+ actualCollection = collection
+ },
+ ).handleDeleteCollectionTapped(expectedCollection)
+
+ assertEquals(expectedCollection, actualCollection)
+ assertNotNull(Collections.removed.testGetValue())
+ val recordedEvents = Collections.removed.testGetValue()!!
+ assertEquals(1, recordedEvents.size)
+ assertEquals(null, recordedEvents.single().extra)
+ }
+
+ @Test
+ fun handleRenameCollectionTapped() {
+ val collection = mockk<TabCollection> {
+ every { id } returns 3L
+ }
+ createController().handleRenameCollectionTapped(collection)
+
+ assertNotNull(Collections.renameButton.testGetValue())
+ val recordedEvents = Collections.renameButton.testGetValue()!!
+ assertEquals(1, recordedEvents.size)
+ assertEquals(null, recordedEvents.single().extra)
+
+ verify {
+ navController.navigate(
+ match<NavDirections> { it.actionId == R.id.action_global_collectionCreationFragment },
+ null,
+ )
+ }
+ }
+
+ @Test
+ fun handleSelectDefaultTopSite() {
+ val topSite = TopSite.Default(
+ id = 1L,
+ title = "Mozilla",
+ url = "mozilla.org",
+ createdAt = 0,
+ )
+ val controller = spyk(createController())
+
+ every { controller.getAvailableSearchEngines() } returns listOf(searchEngine)
+
+ controller.handleSelectTopSite(topSite, position = 0)
+
+ assertNotNull(TopSites.openInNewTab.testGetValue())
+ assertEquals(1, TopSites.openInNewTab.testGetValue()!!.size)
+ assertNull(TopSites.openInNewTab.testGetValue()!!.single().extra)
+
+ assertNotNull(TopSites.openDefault.testGetValue())
+ assertEquals(1, TopSites.openDefault.testGetValue()!!.size)
+ assertNull(TopSites.openDefault.testGetValue()!!.single().extra)
+
+ verify {
+ tabsUseCases.addTab.invoke(
+ url = topSite.url,
+ selectTab = true,
+ startLoading = true,
+ )
+ }
+ verify { navController.navigate(R.id.browserFragment) }
+ }
+
+ @Test
+ fun handleSelectNonDefaultTopSite() {
+ val topSite = TopSite.Frecent(
+ id = 1L,
+ title = "Mozilla",
+ url = "mozilla.org",
+ createdAt = 0,
+ )
+ val controller = spyk(createController())
+
+ every { controller.getAvailableSearchEngines() } returns listOf(searchEngine)
+
+ controller.handleSelectTopSite(topSite, position = 0)
+
+ assertNotNull(TopSites.openInNewTab.testGetValue())
+ assertEquals(1, TopSites.openInNewTab.testGetValue()!!.size)
+ assertNull(TopSites.openInNewTab.testGetValue()!!.single().extra)
+
+ verify {
+ tabsUseCases.addTab.invoke(
+ url = topSite.url,
+ selectTab = true,
+ startLoading = true,
+ )
+ }
+ verify { navController.navigate(R.id.browserFragment) }
+ }
+
+ @Test
+ fun `GIVEN existing tab for url WHEN Default TopSite selected THEN open new tab`() {
+ val url = "mozilla.org"
+ val existingTabForUrl = createTab(url = url)
+
+ store = BrowserStore(
+ BrowserState(
+ tabs = listOf(existingTabForUrl),
+ search = SearchState(
+ regionSearchEngines = listOf(searchEngine),
+ ),
+ ),
+ )
+
+ val topSite = TopSite.Default(
+ id = 1L,
+ title = "Mozilla",
+ url = url,
+ createdAt = 0,
+ )
+ val controller = spyk(createController())
+
+ every { controller.getAvailableSearchEngines() } returns listOf(searchEngine)
+
+ controller.handleSelectTopSite(topSite, position = 0)
+
+ assertNotNull(TopSites.openInNewTab.testGetValue())
+ assertEquals(1, TopSites.openInNewTab.testGetValue()!!.size)
+ assertNull(TopSites.openInNewTab.testGetValue()!!.single().extra)
+
+ assertNotNull(TopSites.openDefault.testGetValue())
+ assertEquals(1, TopSites.openDefault.testGetValue()!!.size)
+ assertNull(TopSites.openDefault.testGetValue()!!.single().extra)
+
+ verify {
+ tabsUseCases.addTab.invoke(
+ url = topSite.url,
+ selectTab = true,
+ startLoading = true,
+ )
+ }
+ verify { navController.navigate(R.id.browserFragment) }
+ }
+
+ @Test
+ fun `GIVEN existing tab for url WHEN Provided TopSite selected THEN open new tab`() {
+ val url = "mozilla.org"
+ val existingTabForUrl = createTab(url = url)
+
+ store = BrowserStore(
+ BrowserState(
+ tabs = listOf(existingTabForUrl),
+ search = SearchState(
+ regionSearchEngines = listOf(searchEngine),
+ ),
+ ),
+ )
+
+ val topSite = TopSite.Provided(
+ id = 1L,
+ title = "Mozilla",
+ url = url,
+ clickUrl = "",
+ imageUrl = "",
+ impressionUrl = "",
+ createdAt = 0,
+ )
+ val position = 0
+ val controller = spyk(createController())
+
+ every { controller.getAvailableSearchEngines() } returns listOf(searchEngine)
+
+ controller.handleSelectTopSite(topSite, position)
+
+ assertNotNull(TopSites.openInNewTab.testGetValue())
+ assertEquals(1, TopSites.openInNewTab.testGetValue()!!.size)
+ assertNull(TopSites.openInNewTab.testGetValue()!!.single().extra)
+
+ assertNotNull(TopSites.openContileTopSite.testGetValue())
+ assertEquals(1, TopSites.openContileTopSite.testGetValue()!!.size)
+ assertNull(TopSites.openContileTopSite.testGetValue()!!.single().extra)
+
+ verify {
+ tabsUseCases.addTab.invoke(
+ url = topSite.url,
+ selectTab = true,
+ startLoading = true,
+ )
+ }
+ verify { controller.submitTopSitesImpressionPing(topSite, position) }
+ verify { navController.navigate(R.id.browserFragment) }
+ }
+
+ @Test
+ fun `GIVEN existing tab for url WHEN Frecent TopSite selected THEN navigate to tab`() {
+ val url = "mozilla.org"
+ val existingTabForUrl = createTab(url = url)
+
+ store = BrowserStore(
+ BrowserState(
+ tabs = listOf(existingTabForUrl),
+ search = SearchState(
+ regionSearchEngines = listOf(searchEngine),
+ ),
+ ),
+ )
+
+ val topSite = TopSite.Frecent(
+ id = 1L,
+ title = "Mozilla",
+ url = url,
+ createdAt = 0,
+ )
+ val controller = spyk(createController())
+
+ every { controller.getAvailableSearchEngines() } returns listOf(searchEngine)
+
+ controller.handleSelectTopSite(topSite, position = 0)
+
+ assertNull(TopSites.openInNewTab.testGetValue())
+
+ assertNotNull(TopSites.openFrecency.testGetValue())
+ assertEquals(1, TopSites.openFrecency.testGetValue()!!.size)
+ assertNull(TopSites.openFrecency.testGetValue()!!.single().extra)
+
+ verify {
+ selectTabUseCase.invoke(existingTabForUrl.id)
+ navController.navigate(R.id.browserFragment)
+ }
+ }
+
+ @Test
+ fun `GIVEN existing tab for url WHEN Pinned TopSite selected THEN navigate to tab`() {
+ val url = "mozilla.org"
+ val existingTabForUrl = createTab(url = url)
+
+ store = BrowserStore(
+ BrowserState(
+ tabs = listOf(existingTabForUrl),
+ search = SearchState(
+ regionSearchEngines = listOf(searchEngine),
+ ),
+ ),
+ )
+
+ val topSite = TopSite.Pinned(
+ id = 1L,
+ title = "Mozilla",
+ url = url,
+ createdAt = 0,
+ )
+ val controller = spyk(createController())
+
+ every { controller.getAvailableSearchEngines() } returns listOf(searchEngine)
+
+ controller.handleSelectTopSite(topSite, position = 0)
+
+ assertNull(TopSites.openInNewTab.testGetValue())
+
+ assertNotNull(TopSites.openPinned.testGetValue())
+ assertEquals(1, TopSites.openPinned.testGetValue()!!.size)
+ assertNull(TopSites.openPinned.testGetValue()!!.single().extra)
+
+ verify {
+ selectTabUseCase.invoke(existingTabForUrl.id)
+ navController.navigate(R.id.browserFragment)
+ }
+ }
+
+ @Test
+ fun handleSelectGoogleDefaultTopSiteUS() {
+ val topSite = TopSite.Default(
+ id = 1L,
+ title = "Google",
+ url = SupportUtils.GOOGLE_URL,
+ createdAt = 0,
+ )
+ val controller = spyk(createController())
+
+ every { controller.getAvailableSearchEngines() } returns listOf(searchEngine)
+
+ store.dispatch(SearchAction.SetRegionAction(RegionState("US", "US"))).joinBlocking()
+
+ controller.handleSelectTopSite(topSite, position = 0)
+
+ assertNotNull(TopSites.openInNewTab.testGetValue())
+ assertEquals(1, TopSites.openInNewTab.testGetValue()!!.size)
+ assertNull(TopSites.openInNewTab.testGetValue()!!.single().extra)
+
+ assertNotNull(TopSites.openDefault.testGetValue())
+ assertEquals(1, TopSites.openDefault.testGetValue()!!.size)
+ assertNull(TopSites.openDefault.testGetValue()!!.single().extra)
+
+ assertNotNull(TopSites.openGoogleSearchAttribution.testGetValue())
+ assertEquals(1, TopSites.openGoogleSearchAttribution.testGetValue()!!.size)
+ assertNull(TopSites.openGoogleSearchAttribution.testGetValue()!!.single().extra)
+
+ verify {
+ tabsUseCases.addTab.invoke(
+ url = SupportUtils.GOOGLE_US_URL,
+ selectTab = true,
+ startLoading = true,
+ )
+ }
+ verify { navController.navigate(R.id.browserFragment) }
+ }
+
+ @Test
+ fun handleSelectGoogleDefaultTopSiteXX() {
+ val topSite = TopSite.Default(
+ id = 1L,
+ title = "Google",
+ url = SupportUtils.GOOGLE_URL,
+ createdAt = 0,
+ )
+ val controller = spyk(createController())
+
+ every { controller.getAvailableSearchEngines() } returns listOf(searchEngine)
+
+ store.dispatch(SearchAction.SetRegionAction(RegionState("DE", "FR"))).joinBlocking()
+
+ controller.handleSelectTopSite(topSite, position = 0)
+
+ assertNotNull(TopSites.openInNewTab.testGetValue())
+ assertEquals(1, TopSites.openInNewTab.testGetValue()!!.size)
+ assertNull(TopSites.openInNewTab.testGetValue()!!.single().extra)
+
+ assertNotNull(TopSites.openDefault.testGetValue())
+ assertEquals(1, TopSites.openDefault.testGetValue()!!.size)
+ assertNull(TopSites.openDefault.testGetValue()!!.single().extra)
+
+ assertNotNull(TopSites.openGoogleSearchAttribution.testGetValue())
+ assertEquals(1, TopSites.openGoogleSearchAttribution.testGetValue()!!.size)
+ assertNull(TopSites.openGoogleSearchAttribution.testGetValue()!!.single().extra)
+
+ verify {
+ tabsUseCases.addTab.invoke(
+ SupportUtils.GOOGLE_XX_URL,
+ selectTab = true,
+ startLoading = true,
+ )
+ }
+ verify { navController.navigate(R.id.browserFragment) }
+ }
+
+ @Test
+ fun handleSelectGoogleDefaultTopSite_EventPerformedSearchTopSite() {
+ assertNull(Events.performedSearch.testGetValue())
+
+ val topSite = TopSite.Default(
+ id = 1L,
+ title = "Google",
+ url = SupportUtils.GOOGLE_URL,
+ createdAt = 0,
+ )
+ val controller = spyk(createController())
+
+ every { controller.getAvailableSearchEngines() } returns listOf(googleSearchEngine)
+
+ try {
+ mockkStatic("mozilla.components.browser.state.state.SearchStateKt")
+
+ every { any<SearchState>().selectedOrDefaultSearchEngine } returns googleSearchEngine
+
+ controller.handleSelectTopSite(topSite, position = 0)
+
+ assertNotNull(Events.performedSearch.testGetValue())
+
+ assertNotNull(TopSites.openDefault.testGetValue())
+ assertEquals(1, TopSites.openDefault.testGetValue()!!.size)
+ assertNull(TopSites.openDefault.testGetValue()!!.single().extra)
+
+ assertNotNull(TopSites.openGoogleSearchAttribution.testGetValue())
+ assertEquals(1, TopSites.openGoogleSearchAttribution.testGetValue()!!.size)
+ assertNull(TopSites.openGoogleSearchAttribution.testGetValue()!!.single().extra)
+ } finally {
+ unmockkStatic("mozilla.components.browser.state.state.SearchStateKt")
+ }
+ }
+
+ @Test
+ fun handleSelectDuckDuckGoTopSite_EventPerformedSearchTopSite() {
+ assertNull(Events.performedSearch.testGetValue())
+
+ val topSite = TopSite.Pinned(
+ id = 1L,
+ title = "DuckDuckGo",
+ url = "https://duckduckgo.com",
+ createdAt = 0,
+ )
+ val controller = spyk(createController())
+
+ every { controller.getAvailableSearchEngines() } returns listOf(
+ googleSearchEngine,
+ duckDuckGoSearchEngine,
+ )
+
+ try {
+ mockkStatic("mozilla.components.browser.state.state.SearchStateKt")
+
+ every { any<SearchState>().selectedOrDefaultSearchEngine } returns googleSearchEngine
+
+ controller.handleSelectTopSite(topSite, position = 0)
+
+ assertNotNull(Events.performedSearch.testGetValue())
+ } finally {
+ unmockkStatic("mozilla.components.browser.state.state.SearchStateKt")
+ }
+ }
+
+ @Test
+ fun handleSelectGooglePinnedTopSiteUS() {
+ val topSite = TopSite.Pinned(
+ id = 1L,
+ title = "Google",
+ url = SupportUtils.GOOGLE_URL,
+ createdAt = 0,
+ )
+ val controller = spyk(createController())
+
+ every { controller.getAvailableSearchEngines() } returns listOf(searchEngine)
+
+ store.dispatch(SearchAction.SetRegionAction(RegionState("US", "US"))).joinBlocking()
+
+ controller.handleSelectTopSite(topSite, position = 0)
+
+ assertNotNull(TopSites.openInNewTab.testGetValue())
+ assertEquals(1, TopSites.openInNewTab.testGetValue()!!.size)
+ assertNull(TopSites.openInNewTab.testGetValue()!!.single().extra)
+
+ assertNotNull(TopSites.openPinned.testGetValue())
+ assertEquals(1, TopSites.openPinned.testGetValue()!!.size)
+ assertNull(TopSites.openPinned.testGetValue()!!.single().extra)
+
+ assertNotNull(TopSites.openGoogleSearchAttribution.testGetValue())
+ assertEquals(1, TopSites.openGoogleSearchAttribution.testGetValue()!!.size)
+ assertNull(TopSites.openGoogleSearchAttribution.testGetValue()!!.single().extra)
+
+ verify {
+ tabsUseCases.addTab.invoke(
+ SupportUtils.GOOGLE_US_URL,
+ selectTab = true,
+ startLoading = true,
+ )
+ }
+ verify { navController.navigate(R.id.browserFragment) }
+ }
+
+ @Test
+ fun handleSelectGooglePinnedTopSiteXX() {
+ val topSite = TopSite.Pinned(
+ id = 1L,
+ title = "Google",
+ url = SupportUtils.GOOGLE_URL,
+ createdAt = 0,
+ )
+ val controller = spyk(createController())
+
+ every { controller.getAvailableSearchEngines() } returns listOf(searchEngine)
+
+ store.dispatch(SearchAction.SetRegionAction(RegionState("DE", "FR"))).joinBlocking()
+
+ controller.handleSelectTopSite(topSite, position = 0)
+
+ assertNotNull(TopSites.openInNewTab.testGetValue())
+ assertEquals(1, TopSites.openInNewTab.testGetValue()!!.size)
+ assertNull(TopSites.openInNewTab.testGetValue()!!.single().extra)
+
+ assertNotNull(TopSites.openPinned.testGetValue())
+ assertEquals(1, TopSites.openPinned.testGetValue()!!.size)
+ assertNull(TopSites.openPinned.testGetValue()!!.single().extra)
+
+ assertNotNull(TopSites.openGoogleSearchAttribution.testGetValue())
+ assertEquals(1, TopSites.openGoogleSearchAttribution.testGetValue()!!.size)
+ assertNull(TopSites.openGoogleSearchAttribution.testGetValue()!!.single().extra)
+
+ verify {
+ tabsUseCases.addTab.invoke(
+ SupportUtils.GOOGLE_XX_URL,
+ selectTab = true,
+ startLoading = true,
+ )
+ }
+ verify { navController.navigate(R.id.browserFragment) }
+ }
+
+ @Test
+ fun handleSelectGoogleFrecentTopSiteUS() {
+ val topSite = TopSite.Frecent(
+ id = 1L,
+ title = "Google",
+ url = SupportUtils.GOOGLE_URL,
+ createdAt = 0,
+ )
+ val controller = spyk(createController())
+
+ every { controller.getAvailableSearchEngines() } returns listOf(searchEngine)
+
+ store.dispatch(SearchAction.SetRegionAction(RegionState("US", "US"))).joinBlocking()
+
+ controller.handleSelectTopSite(topSite, position = 0)
+
+ assertNotNull(TopSites.openInNewTab.testGetValue())
+ assertEquals(1, TopSites.openInNewTab.testGetValue()!!.size)
+ assertNull(TopSites.openInNewTab.testGetValue()!!.single().extra)
+
+ assertNotNull(TopSites.openFrecency.testGetValue())
+ assertEquals(1, TopSites.openFrecency.testGetValue()!!.size)
+ assertNull(TopSites.openFrecency.testGetValue()!!.single().extra)
+
+ assertNotNull(TopSites.openGoogleSearchAttribution.testGetValue())
+ assertEquals(1, TopSites.openGoogleSearchAttribution.testGetValue()!!.size)
+ assertNull(TopSites.openGoogleSearchAttribution.testGetValue()!!.single().extra)
+
+ verify {
+ tabsUseCases.addTab.invoke(
+ SupportUtils.GOOGLE_US_URL,
+ selectTab = true,
+ startLoading = true,
+ )
+ }
+ verify { navController.navigate(R.id.browserFragment) }
+ }
+
+ @Test
+ fun handleSelectGoogleFrecentTopSiteXX() {
+ val topSite = TopSite.Frecent(
+ id = 1L,
+ title = "Google",
+ url = SupportUtils.GOOGLE_URL,
+ createdAt = 0,
+ )
+ val controller = spyk(createController())
+
+ every { controller.getAvailableSearchEngines() } returns listOf(searchEngine)
+
+ store.dispatch(SearchAction.SetRegionAction(RegionState("DE", "FR"))).joinBlocking()
+
+ controller.handleSelectTopSite(topSite, position = 0)
+
+ assertNotNull(TopSites.openInNewTab.testGetValue())
+ assertEquals(1, TopSites.openInNewTab.testGetValue()!!.size)
+ assertNull(TopSites.openInNewTab.testGetValue()!!.single().extra)
+
+ assertNotNull(TopSites.openFrecency.testGetValue())
+ assertEquals(1, TopSites.openFrecency.testGetValue()!!.size)
+ assertNull(TopSites.openFrecency.testGetValue()!!.single().extra)
+
+ assertNotNull(TopSites.openGoogleSearchAttribution.testGetValue())
+ assertEquals(1, TopSites.openGoogleSearchAttribution.testGetValue()!!.size)
+ assertNull(TopSites.openGoogleSearchAttribution.testGetValue()!!.single().extra)
+
+ verify {
+ tabsUseCases.addTab.invoke(
+ SupportUtils.GOOGLE_XX_URL,
+ selectTab = true,
+ startLoading = true,
+ )
+ }
+ verify { navController.navigate(R.id.browserFragment) }
+ }
+
+ @Test
+ fun handleSelectProvidedTopSite() {
+ val topSite = TopSite.Provided(
+ id = 1L,
+ title = "Mozilla",
+ url = "mozilla.org",
+ clickUrl = "",
+ imageUrl = "",
+ impressionUrl = "",
+ createdAt = 0,
+ )
+ val position = 0
+ val controller = spyk(createController())
+
+ every { controller.getAvailableSearchEngines() } returns listOf(searchEngine)
+
+ controller.handleSelectTopSite(topSite, position)
+
+ assertNotNull(TopSites.openInNewTab.testGetValue())
+ assertEquals(1, TopSites.openInNewTab.testGetValue()!!.size)
+ assertNull(TopSites.openInNewTab.testGetValue()!!.single().extra)
+
+ assertNotNull(TopSites.openContileTopSite.testGetValue())
+ assertEquals(1, TopSites.openContileTopSite.testGetValue()!!.size)
+ assertNull(TopSites.openContileTopSite.testGetValue()!!.single().extra)
+
+ verify {
+ tabsUseCases.addTab.invoke(
+ url = topSite.url,
+ selectTab = true,
+ startLoading = true,
+ )
+ }
+ verify { controller.submitTopSitesImpressionPing(topSite, position) }
+ verify { navController.navigate(R.id.browserFragment) }
+ }
+
+ @Test
+ fun `GIVEN a provided top site WHEN the provided top site is clicked THEN submit a top site impression ping`() {
+ val controller = spyk(createController())
+ val topSite = TopSite.Provided(
+ id = 3,
+ title = "Mozilla",
+ url = "https://mozilla.com",
+ clickUrl = "https://mozilla.com/click",
+ imageUrl = "https://test.com/image2.jpg",
+ impressionUrl = "https://example.com",
+ createdAt = 3,
+ )
+ val position = 0
+ assertNull(TopSites.contileImpression.testGetValue())
+
+ var topSiteImpressionPinged = false
+ Pings.topsitesImpression.testBeforeNextSubmit {
+ assertNotNull(TopSites.contileTileId.testGetValue())
+ assertEquals(3L, TopSites.contileTileId.testGetValue())
+
+ assertNotNull(TopSites.contileAdvertiser.testGetValue())
+ assertEquals("mozilla", TopSites.contileAdvertiser.testGetValue())
+
+ assertNotNull(TopSites.contileReportingUrl.testGetValue())
+ assertEquals(topSite.clickUrl, TopSites.contileReportingUrl.testGetValue())
+
+ topSiteImpressionPinged = true
+ }
+
+ controller.submitTopSitesImpressionPing(topSite, position)
+
+ assertNotNull(TopSites.contileClick.testGetValue())
+
+ val event = TopSites.contileClick.testGetValue()!!
+
+ assertEquals(1, event.size)
+ assertEquals("top_sites", event[0].category)
+ assertEquals("contile_click", event[0].name)
+ assertEquals("1", event[0].extra!!["position"])
+ assertEquals("newtab", event[0].extra!!["source"])
+
+ assertTrue(topSiteImpressionPinged)
+ }
+
+ @Test
+ fun `WHEN the default Google top site is removed THEN the correct metric is recorded`() {
+ val controller = spyk(createController())
+ val topSite = TopSite.Default(
+ id = 1L,
+ title = "Google",
+ url = SupportUtils.GOOGLE_URL,
+ createdAt = 0,
+ )
+ assertNull(TopSites.remove.testGetValue())
+ assertNull(TopSites.googleTopSiteRemoved.testGetValue())
+
+ controller.handleRemoveTopSiteClicked(topSite)
+
+ assertNotNull(TopSites.googleTopSiteRemoved.testGetValue())
+ assertEquals(1, TopSites.googleTopSiteRemoved.testGetValue()!!.size)
+ assertNull(TopSites.googleTopSiteRemoved.testGetValue()!!.single().extra)
+
+ assertNotNull(TopSites.remove.testGetValue())
+ assertEquals(1, TopSites.remove.testGetValue()!!.size)
+ assertNull(TopSites.remove.testGetValue()!!.single().extra)
+ }
+
+ @Test
+ fun `WHEN top site is removed THEN the undo snackbar is called`() {
+ val mozillaTopSite = TopSite.Default(
+ id = 1L,
+ title = "Mozilla",
+ url = "https://mozilla.org",
+ null,
+ )
+ var undoSnackbarCalled = false
+ var undoSnackbarShownFor = "TopSiteName"
+
+ createController(
+ showUndoSnackbarForTopSite = { topSite ->
+ undoSnackbarCalled = true
+ undoSnackbarShownFor = topSite.title.toString()
+ },
+ ).handleRemoveTopSiteClicked(mozillaTopSite)
+
+ assertEquals(true, undoSnackbarCalled)
+ assertEquals("Mozilla", undoSnackbarShownFor)
+ }
+
+ @Test
+ fun `GIVEN exactly the required amount of downloaded thumbnails with no errors WHEN handling wallpaper dialog THEN dialog is shown`() {
+ val wallpaperState = WallpaperState.default.copy(
+ availableWallpapers = makeFakeRemoteWallpapers(
+ THUMBNAILS_SELECTION_COUNT,
+ false,
+ ),
+ )
+ assertTrue(createController().handleShowWallpapersOnboardingDialog(wallpaperState))
+ }
+
+ @Test
+ fun `GIVEN more than required amount of downloaded thumbnails with no errors WHEN handling wallpaper dialog THEN dialog is shown`() {
+ val wallpaperState = WallpaperState.default.copy(
+ availableWallpapers = makeFakeRemoteWallpapers(
+ THUMBNAILS_SELECTION_COUNT,
+ false,
+ ),
+ )
+ assertTrue(createController().handleShowWallpapersOnboardingDialog(wallpaperState))
+ }
+
+ @Test
+ fun `GIVEN more than required amount of downloaded thumbnails with some errors WHEN handling wallpaper dialog THEN dialog is shown`() {
+ val wallpaperState = WallpaperState.default.copy(
+ availableWallpapers = makeFakeRemoteWallpapers(
+ THUMBNAILS_SELECTION_COUNT + 2,
+ true,
+ ),
+ )
+ assertTrue(createController().handleShowWallpapersOnboardingDialog(wallpaperState))
+ }
+
+ @Test
+ fun `GIVEN fewer than the required amount of downloaded thumbnails WHEN handling wallpaper dialog THEN the dialog is not shown`() {
+ val wallpaperState = WallpaperState.default.copy(
+ availableWallpapers = makeFakeRemoteWallpapers(
+ THUMBNAILS_SELECTION_COUNT - 1,
+ false,
+ ),
+ )
+ assertFalse(createController().handleShowWallpapersOnboardingDialog(wallpaperState))
+ }
+
+ @Test
+ fun `GIVEN exactly the required amount of downloaded thumbnails with errors WHEN handling wallpaper dialog THEN the dialog is not shown`() {
+ val wallpaperState = WallpaperState.default.copy(
+ availableWallpapers = makeFakeRemoteWallpapers(
+ THUMBNAILS_SELECTION_COUNT,
+ true,
+ ),
+ )
+ assertFalse(createController().handleShowWallpapersOnboardingDialog(wallpaperState))
+ }
+
+ @Test
+ fun `GIVEN app is in private browsing mode WHEN handling wallpaper dialog THEN the dialog is not shown`() {
+ every { activity.browsingModeManager } returns mockk {
+ every { mode } returns mockk {
+ every { isPrivate } returns true
+ }
+ }
+ val wallpaperState = WallpaperState.default.copy(
+ availableWallpapers = makeFakeRemoteWallpapers(
+ THUMBNAILS_SELECTION_COUNT,
+ true,
+ ),
+ )
+
+ assertFalse(createController().handleShowWallpapersOnboardingDialog(wallpaperState))
+ }
+
+ @Test
+ fun handleToggleCollectionExpanded() {
+ val collection = mockk<TabCollection>()
+ createController().handleToggleCollectionExpanded(collection, true)
+ verify { appStore.dispatch(AppAction.CollectionExpanded(collection, true)) }
+ }
+
+ @Test
+ fun handleCreateCollection() {
+ createController().handleCreateCollection()
+
+ verify {
+ navController.navigate(
+ match<NavDirections> { it.actionId == R.id.action_global_tabsTrayFragment },
+ null,
+ )
+ }
+ }
+
+ @Test
+ fun handleRemoveCollectionsPlaceholder() {
+ createController().handleRemoveCollectionsPlaceholder()
+
+ val recordedEvents = Collections.placeholderCancel.testGetValue()!!
+ assertEquals(1, recordedEvents.size)
+ assertEquals(null, recordedEvents.single().extra)
+ verify {
+ settings.showCollectionsPlaceholderOnHome = false
+ appStore.dispatch(AppAction.RemoveCollectionsPlaceholder)
+ }
+ }
+
+ @Test
+ fun `WHEN handleReportSessionMetrics is called AND there are zero recent tabs THEN report Event#RecentTabsSectionIsNotVisible`() {
+ assertNull(RecentTabs.sectionVisible.testGetValue())
+
+ every { appState.recentTabs } returns emptyList()
+ createController().handleReportSessionMetrics(appState)
+ assertNotNull(RecentTabs.sectionVisible.testGetValue())
+ assertFalse(RecentTabs.sectionVisible.testGetValue()!!)
+ }
+
+ @Test
+ fun `WHEN handleReportSessionMetrics is called AND there is at least one recent tab THEN report Event#RecentTabsSectionIsVisible`() {
+ assertNull(RecentTabs.sectionVisible.testGetValue())
+
+ val recentTab: RecentTab = mockk(relaxed = true)
+ every { appState.recentTabs } returns listOf(recentTab)
+ createController().handleReportSessionMetrics(appState)
+
+ assertNotNull(RecentTabs.sectionVisible.testGetValue())
+ assertTrue(RecentTabs.sectionVisible.testGetValue()!!)
+ }
+
+ @Test
+ fun `WHEN handleReportSessionMetrics is called AND there are zero recent bookmarks THEN report Event#RecentBookmarkCount(0)`() {
+ every { appState.recentBookmarks } returns emptyList()
+ every { appState.recentTabs } returns emptyList()
+ assertNull(RecentBookmarks.recentBookmarksCount.testGetValue())
+
+ createController().handleReportSessionMetrics(appState)
+
+ assertNotNull(RecentBookmarks.recentBookmarksCount.testGetValue())
+ assertEquals(0L, RecentBookmarks.recentBookmarksCount.testGetValue())
+ }
+
+ @Test
+ fun `WHEN handleReportSessionMetrics is called AND there is at least one recent bookmark THEN report Event#RecentBookmarkCount(1)`() {
+ val recentBookmark: RecentBookmark = mockk(relaxed = true)
+ every { appState.recentBookmarks } returns listOf(recentBookmark)
+ every { appState.recentTabs } returns emptyList()
+ assertNull(RecentBookmarks.recentBookmarksCount.testGetValue())
+
+ createController().handleReportSessionMetrics(appState)
+
+ assertNotNull(RecentBookmarks.recentBookmarksCount.testGetValue())
+ assertEquals(1L, RecentBookmarks.recentBookmarksCount.testGetValue())
+ }
+
+ @Test
+ fun `WHEN handleTopSiteSettingsClicked is called THEN navigate to the HomeSettingsFragment AND report the interaction`() {
+ createController().handleTopSiteSettingsClicked()
+
+ assertNotNull(TopSites.contileSettings.testGetValue())
+ assertEquals(1, TopSites.contileSettings.testGetValue()!!.size)
+ assertNull(TopSites.contileSettings.testGetValue()!!.single().extra)
+ verify {
+ navController.navigate(
+ match<NavDirections> {
+ it.actionId == R.id.action_global_homeSettingsFragment
+ },
+ null,
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN handleSponsorPrivacyClicked is called THEN navigate to the privacy webpage AND report the interaction`() {
+ createController().handleSponsorPrivacyClicked()
+
+ assertNotNull(TopSites.contileSponsorsAndPrivacy.testGetValue())
+ assertEquals(1, TopSites.contileSponsorsAndPrivacy.testGetValue()!!.size)
+ assertNull(TopSites.contileSponsorsAndPrivacy.testGetValue()!!.single().extra)
+ verify {
+ activity.openToBrowserAndLoad(
+ searchTermOrURL = SupportUtils.getGenericSumoURLForTopic(SupportUtils.SumoTopic.SPONSOR_PRIVACY),
+ newTab = true,
+ from = BrowserDirection.FromHome,
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN top site long clicked is called THEN report the top site long click telemetry`() {
+ assertNull(TopSites.longPress.testGetValue())
+
+ val topSite = TopSite.Provided(
+ id = 1L,
+ title = "Mozilla",
+ url = "mozilla.org",
+ clickUrl = "",
+ imageUrl = "",
+ impressionUrl = "",
+ createdAt = 0,
+ )
+
+ createController().handleTopSiteLongClicked(topSite)
+
+ assertEquals(topSite.type, TopSites.longPress.testGetValue()!!.single().extra!!["type"])
+ }
+
+ @Test
+ fun `WHEN handleOpenInPrivateTabClicked is called with a TopSite#Provided site THEN Event#TopSiteOpenContileInPrivateTab is reported`() {
+ val topSite = TopSite.Provided(
+ id = 1L,
+ title = "Mozilla",
+ url = "mozilla.org",
+ clickUrl = "",
+ imageUrl = "",
+ impressionUrl = "",
+ createdAt = 0,
+ )
+ createController().handleOpenInPrivateTabClicked(topSite)
+
+ assertNotNull(TopSites.openContileInPrivateTab.testGetValue())
+ assertEquals(1, TopSites.openContileInPrivateTab.testGetValue()!!.size)
+ assertNull(TopSites.openContileInPrivateTab.testGetValue()!!.single().extra)
+ }
+
+ @Test
+ fun `WHEN handleOpenInPrivateTabClicked is called with a Default, Pinned, or Frecent top site THEN openInPrivateTab event is recorded`() {
+ val controller = createController()
+ val topSite1 = TopSite.Default(
+ id = 1L,
+ title = "Mozilla",
+ url = "mozilla.org",
+ createdAt = 0,
+ )
+ val topSite2 = TopSite.Pinned(
+ id = 1L,
+ title = "Mozilla",
+ url = "mozilla.org",
+ createdAt = 0,
+ )
+ val topSite3 = TopSite.Frecent(
+ id = 1L,
+ title = "Mozilla",
+ url = "mozilla.org",
+ createdAt = 0,
+ )
+ assertNull(TopSites.openInPrivateTab.testGetValue())
+
+ controller.handleOpenInPrivateTabClicked(topSite1)
+ controller.handleOpenInPrivateTabClicked(topSite2)
+ controller.handleOpenInPrivateTabClicked(topSite3)
+
+ assertNotNull(TopSites.openInPrivateTab.testGetValue())
+ assertEquals(3, TopSites.openInPrivateTab.testGetValue()!!.size)
+ for (event in TopSites.openInPrivateTab.testGetValue()!!) {
+ assertNull(event.extra)
+ }
+ }
+
+ @Test
+ fun `WHEN handleMessageClicked and handleMessageClosed are called THEN delegate to messageController`() {
+ val controller = createController()
+ val message = mockk<Message>()
+
+ controller.handleMessageClicked(message)
+ controller.handleMessageClosed(message)
+
+ verify {
+ messageController.onMessagePressed(message)
+ }
+ verify {
+ messageController.onMessageDismissed(message)
+ }
+ }
+
+ private fun createController(
+ registerCollectionStorageObserver: () -> Unit = { },
+ showTabTray: () -> Unit = { },
+ removeCollectionWithUndo: (tabCollection: TabCollection) -> Unit = { },
+ showUndoSnackbarForTopSite: (topSite: TopSite) -> Unit = { },
+ ): DefaultSessionControlController {
+ return DefaultSessionControlController(
+ activity = activity,
+ settings = settings,
+ engine = engine,
+ store = store,
+ messageController = messageController,
+ tabCollectionStorage = tabCollectionStorage,
+ addTabUseCase = tabsUseCases.addTab,
+ restoreUseCase = mockk(relaxed = true),
+ reloadUrlUseCase = reloadUrlUseCase.reload,
+ selectTabUseCase = selectTabUseCase.selectTab,
+ appStore = appStore,
+ navController = navController,
+ viewLifecycleScope = scope,
+ registerCollectionStorageObserver = registerCollectionStorageObserver,
+ removeCollectionWithUndo = removeCollectionWithUndo,
+ showUndoSnackbarForTopSite = showUndoSnackbarForTopSite,
+ showTabTray = showTabTray,
+ )
+ }
+
+ private fun makeFakeRemoteWallpapers(size: Int, hasError: Boolean): List<Wallpaper> {
+ val list = mutableListOf<Wallpaper>()
+ for (i in 0 until size) {
+ if (hasError && i == 0) {
+ list.add(makeFakeRemoteWallpaper(Wallpaper.ImageFileState.Error))
+ } else {
+ list.add(makeFakeRemoteWallpaper(Wallpaper.ImageFileState.Downloaded))
+ }
+ }
+ return list
+ }
+
+ private fun makeFakeRemoteWallpaper(
+ thumbnailFileState: Wallpaper.ImageFileState = Wallpaper.ImageFileState.Unavailable,
+ ) = Wallpaper(
+ name = "name",
+ collection = Wallpaper.Collection(
+ name = Wallpaper.firefoxCollectionName,
+ heading = null,
+ description = null,
+ availableLocales = null,
+ startDate = null,
+ endDate = null,
+ learnMoreUrl = null,
+ ),
+ textColor = null,
+ cardColorLight = null,
+ cardColorDark = null,
+ thumbnailFileState = thumbnailFileState,
+ assetsFileState = Wallpaper.ImageFileState.Unavailable,
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/HomeFragmentTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/HomeFragmentTest.kt
new file mode 100644
index 0000000000..d7f659b26d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/HomeFragmentTest.kt
@@ -0,0 +1,192 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home
+
+import android.content.Context
+import android.view.ViewGroup
+import androidx.lifecycle.LifecycleCoroutineScope
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.spyk
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import kotlinx.coroutines.CoroutineScope
+import mozilla.components.browser.state.search.SearchEngine
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.SearchState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.feature.top.sites.TopSite
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.FenixApplication
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.R
+import org.mozilla.fenix.components.Core
+import org.mozilla.fenix.ext.application
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.home.HomeFragment.Companion.AMAZON_SPONSORED_TITLE
+import org.mozilla.fenix.home.HomeFragment.Companion.EBAY_SPONSORED_TITLE
+import org.mozilla.fenix.home.HomeFragment.Companion.TOAST_ELEVATION
+import org.mozilla.fenix.utils.Settings
+import org.mozilla.fenix.utils.allowUndo
+
+class HomeFragmentTest {
+
+ private lateinit var settings: Settings
+ private lateinit var context: Context
+ private lateinit var core: Core
+ private lateinit var homeFragment: HomeFragment
+
+ @Before
+ fun setup() {
+ settings = mockk(relaxed = true)
+ context = mockk(relaxed = true)
+ core = mockk(relaxed = true)
+
+ val fenixApplication: FenixApplication = mockk(relaxed = true)
+
+ homeFragment = spyk(HomeFragment())
+
+ every { context.application } returns fenixApplication
+ every { homeFragment.context } answers { context }
+ every { context.components.settings } answers { settings }
+ every { context.components.core } answers { core }
+ }
+
+ @Test
+ fun `WHEN getTopSitesConfig is called THEN it returns TopSitesConfig with non-null frecencyConfig`() {
+ every { settings.topSitesMaxLimit } returns 10
+
+ val topSitesConfig = homeFragment.getTopSitesConfig()
+
+ assertNotNull(topSitesConfig.frecencyConfig)
+ }
+
+ @Test
+ fun `GIVEN a topSitesMaxLimit WHEN getTopSitesConfig is called THEN it returns TopSitesConfig with totalSites = topSitesMaxLimit`() {
+ val topSitesMaxLimit = 10
+ every { settings.topSitesMaxLimit } returns topSitesMaxLimit
+
+ val topSitesConfig = homeFragment.getTopSitesConfig()
+
+ assertEquals(topSitesMaxLimit, topSitesConfig.totalSites)
+ }
+
+ @Test
+ fun `GIVEN the selected search engine is set to eBay WHEN getTopSitesConfig is called THEN providerFilter filters the eBay provided top sites`() {
+ val searchEngine: SearchEngine = mockk()
+ val browserStore = BrowserStore(
+ initialState = BrowserState(
+ search = SearchState(
+ regionSearchEngines = listOf(searchEngine),
+ ),
+ ),
+ )
+
+ every { core.store } returns browserStore
+ every { searchEngine.name } returns EBAY_SPONSORED_TITLE
+
+ val eBayTopSite = TopSite.Provided(1L, EBAY_SPONSORED_TITLE, "eBay.com", "", "", "", 0L)
+ val amazonTopSite = TopSite.Provided(2L, AMAZON_SPONSORED_TITLE, "Amazon.com", "", "", "", 0L)
+ val firefoxTopSite = TopSite.Provided(3L, "Firefox", "mozilla.org", "", "", "", 0L)
+ val providedTopSites = listOf(eBayTopSite, amazonTopSite, firefoxTopSite)
+
+ val topSitesConfig = homeFragment.getTopSitesConfig()
+
+ val filteredProvidedSites = providedTopSites.filter {
+ topSitesConfig.providerConfig?.providerFilter?.invoke(it) ?: true
+ }
+ assertTrue(filteredProvidedSites.containsAll(listOf(amazonTopSite, firefoxTopSite)))
+ assertFalse(filteredProvidedSites.contains(eBayTopSite))
+ }
+
+ @Test
+ fun `WHEN configuration changed THEN menu is dismissed`() {
+ val homeMenuView: HomeMenuView = mockk(relaxed = true)
+ homeFragment.homeMenuView = homeMenuView
+
+ homeFragment.onConfigurationChanged(mockk(relaxed = true))
+
+ verify(exactly = 1) { homeMenuView.dismissMenu() }
+ }
+
+ fun `GIVEN the user is in normal mode WHEN checking if should enable wallpaper THEN return true`() {
+ val activity: HomeActivity = mockk {
+ every { themeManager.currentTheme.isPrivate } returns false
+ }
+ every { homeFragment.activity } returns activity
+
+ assertTrue(homeFragment.shouldEnableWallpaper())
+ }
+
+ @Test
+ fun `GIVEN the user is in private mode WHEN checking if should enable wallpaper THEN return false`() {
+ val activity: HomeActivity = mockk {
+ every { themeManager.currentTheme.isPrivate } returns true
+ }
+ every { homeFragment.activity } returns activity
+
+ assertFalse(homeFragment.shouldEnableWallpaper())
+ }
+
+ @Test
+ fun `WHEN a pinned top is removed THEN show the undo snackbar`() {
+ try {
+ val topSite = TopSite.Default(
+ id = 1L,
+ title = "Mozilla",
+ url = "https://mozilla.org",
+ null,
+ )
+ mockkStatic("org.mozilla.fenix.utils.UndoKt")
+ mockkStatic("androidx.lifecycle.LifecycleOwnerKt")
+ val view: ViewGroup = mockk(relaxed = true)
+ val lifecycleScope: LifecycleCoroutineScope = mockk(relaxed = true)
+ every { any<LifecycleOwner>().lifecycleScope } returns lifecycleScope
+ every { homeFragment.getString(R.string.snackbar_top_site_removed) } returns "Mocked Removed Top Site"
+ every { homeFragment.getString(R.string.snackbar_deleted_undo) } returns "Mocked Undo Removal"
+ every {
+ any<CoroutineScope>().allowUndo(
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ )
+ } just Runs
+ every { homeFragment.requireView() } returns view
+
+ homeFragment.showUndoSnackbarForTopSite(topSite)
+
+ verify {
+ lifecycleScope.allowUndo(
+ view,
+ "Mocked Removed Top Site",
+ "Mocked Undo Removal",
+ any(),
+ any(),
+ any(),
+ TOAST_ELEVATION,
+ true,
+ )
+ }
+ } finally {
+ unmockkStatic("org.mozilla.fenix.utils.UndoKt")
+ unmockkStatic("androidx.lifecycle.LifecycleOwnerKt")
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/HomeMenuViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/HomeMenuViewTest.kt
new file mode 100644
index 0000000000..ba7e07e352
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/HomeMenuViewTest.kt
@@ -0,0 +1,255 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home
+
+import android.view.View
+import androidx.lifecycle.LifecycleOwner
+import androidx.navigation.NavController
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.appservices.places.BookmarkRoot
+import mozilla.components.browser.menu.view.MenuButton
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.telemetry.glean.testing.GleanTestRule
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.BrowserDirection
+import org.mozilla.fenix.GleanMetrics.Events
+import org.mozilla.fenix.GleanMetrics.HomeScreen
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.R
+import org.mozilla.fenix.components.accounts.AccountState
+import org.mozilla.fenix.components.accounts.FenixFxAEntryPoint
+import org.mozilla.fenix.ext.nav
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.SupportUtils
+import org.mozilla.fenix.utils.Settings
+import org.mozilla.fenix.whatsnew.WhatsNew
+import java.lang.ref.WeakReference
+import org.mozilla.fenix.GleanMetrics.HomeMenu as HomeMenuMetrics
+
+@RunWith(FenixRobolectricTestRunner::class)
+class HomeMenuViewTest {
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ private lateinit var view: View
+ private lateinit var lifecycleOwner: LifecycleOwner
+ private lateinit var homeActivity: HomeActivity
+ private lateinit var navController: NavController
+ private lateinit var menuButton: MenuButton
+ private lateinit var homeMenuView: HomeMenuView
+
+ @Before
+ fun setup() {
+ view = mockk(relaxed = true)
+ lifecycleOwner = mockk(relaxed = true)
+ homeActivity = mockk(relaxed = true)
+ navController = mockk(relaxed = true)
+
+ menuButton = spyk(MenuButton(testContext))
+
+ homeMenuView = HomeMenuView(
+ view = view,
+ context = testContext,
+ lifecycleOwner = lifecycleOwner,
+ homeActivity = homeActivity,
+ navController = navController,
+ menuButton = WeakReference(menuButton),
+ )
+ }
+
+ @Test
+ fun `WHEN dismiss menu is called THEN the menu is dismissed`() {
+ homeMenuView.dismissMenu()
+
+ verify {
+ menuButton.dismissMenu()
+ }
+ }
+
+ @Test
+ fun `WHEN Settings menu item is tapped THEN navigate to settings fragment and record metrics`() {
+ assertNull(HomeMenuMetrics.settingsItemClicked.testGetValue())
+
+ homeMenuView.onItemTapped(HomeMenu.Item.Settings)
+
+ assertNotNull(HomeMenuMetrics.settingsItemClicked.testGetValue())
+
+ verify {
+ navController.nav(
+ R.id.homeFragment,
+ HomeFragmentDirections.actionGlobalSettingsFragment(),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN Customize Home menu item is tapped THEN navigate to home settings fragment and record metrics`() {
+ assertNull(HomeScreen.customizeHomeClicked.testGetValue())
+
+ homeMenuView.onItemTapped(HomeMenu.Item.CustomizeHome)
+
+ assertNotNull(HomeScreen.customizeHomeClicked.testGetValue())
+
+ verify {
+ navController.nav(
+ R.id.homeFragment,
+ HomeFragmentDirections.actionGlobalHomeSettingsFragment(),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN various sync account state WHEN Sync Account menu item is tapped THEN navigate to the appropriate sync fragment`() {
+ homeMenuView.onItemTapped(HomeMenu.Item.SyncAccount(AccountState.AUTHENTICATED))
+
+ verify {
+ navController.nav(
+ R.id.homeFragment,
+ HomeFragmentDirections.actionGlobalAccountSettingsFragment(),
+ )
+ }
+
+ homeMenuView.onItemTapped(HomeMenu.Item.SyncAccount(AccountState.NEEDS_REAUTHENTICATION))
+
+ verify {
+ navController.nav(
+ R.id.homeFragment,
+ HomeFragmentDirections.actionGlobalAccountProblemFragment(
+ entrypoint = FenixFxAEntryPoint.HomeMenu,
+ ),
+ )
+ }
+
+ homeMenuView.onItemTapped(HomeMenu.Item.SyncAccount(AccountState.NO_ACCOUNT))
+
+ verify {
+ navController.nav(
+ R.id.homeFragment,
+ HomeFragmentDirections.actionGlobalTurnOnSync(entrypoint = FenixFxAEntryPoint.HomeMenu),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN Bookmarks menu item is tapped THEN navigate to the bookmarks fragment`() {
+ homeMenuView.onItemTapped(HomeMenu.Item.Bookmarks)
+
+ verify {
+ navController.nav(
+ R.id.homeFragment,
+ HomeFragmentDirections.actionGlobalBookmarkFragment(BookmarkRoot.Mobile.id),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN History menu item is tapped THEN navigate to the history fragment`() {
+ homeMenuView.onItemTapped(HomeMenu.Item.History)
+
+ verify {
+ navController.nav(
+ R.id.homeFragment,
+ HomeFragmentDirections.actionGlobalHistoryFragment(),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN Downloads menu item is tapped THEN navigate to the downloads fragment`() {
+ homeMenuView.onItemTapped(HomeMenu.Item.Downloads)
+
+ verify {
+ navController.nav(
+ R.id.homeFragment,
+ HomeFragmentDirections.actionGlobalDownloadsFragment(),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN Help menu item is tapped THEN open the browser to the SUMO help page and record metrics`() {
+ assertNull(HomeMenuMetrics.helpTapped.testGetValue())
+
+ homeMenuView.onItemTapped(HomeMenu.Item.Help)
+
+ assertNotNull(HomeMenuMetrics.helpTapped.testGetValue())
+
+ verify {
+ homeActivity.openToBrowserAndLoad(
+ searchTermOrURL = SupportUtils.getSumoURLForTopic(
+ context = testContext,
+ topic = SupportUtils.SumoTopic.HELP,
+ ),
+ newTab = true,
+ from = BrowserDirection.FromHome,
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN Whats New menu item is tapped THEN open the browser to the SUMO whats new page and record metrics`() {
+ assertNull(Events.whatsNewTapped.testGetValue())
+
+ homeMenuView.onItemTapped(HomeMenu.Item.WhatsNew)
+
+ assertNotNull(Events.whatsNewTapped.testGetValue())
+
+ verify {
+ WhatsNew.userViewedWhatsNew(testContext)
+
+ homeActivity.openToBrowserAndLoad(
+ searchTermOrURL = SupportUtils.WHATS_NEW_URL,
+ newTab = true,
+ from = BrowserDirection.FromHome,
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN Reconnect Sync menu item is tapped THEN navigate to the account problem fragment`() {
+ homeMenuView.onItemTapped(HomeMenu.Item.ReconnectSync)
+
+ verify {
+ navController.nav(
+ R.id.homeFragment,
+ HomeFragmentDirections.actionGlobalAccountProblemFragment(
+ entrypoint = FenixFxAEntryPoint.HomeMenu,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN Extensions menu item is tapped THEN navigate to the addons management fragment`() {
+ homeMenuView.onItemTapped(HomeMenu.Item.Extensions)
+
+ verify {
+ navController.nav(
+ R.id.homeFragment,
+ HomeFragmentDirections.actionGlobalAddonsManagementFragment(),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN Desktop Mode menu item is tapped THEN set the desktop mode settings`() {
+ every { testContext.settings() } returns Settings(testContext)
+
+ homeMenuView.onItemTapped(HomeMenu.Item.DesktopMode(checked = true))
+
+ assertTrue(testContext.settings().openNextTabInDesktopMode)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/PocketUpdatesMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/PocketUpdatesMiddlewareTest.kt
new file mode 100644
index 0000000000..4f7e194824
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/PocketUpdatesMiddlewareTest.kt
@@ -0,0 +1,238 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home
+
+import androidx.datastore.core.DataStore
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOf
+import mozilla.components.service.pocket.PocketStoriesService
+import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppAction
+import org.mozilla.fenix.components.appstate.AppState
+import org.mozilla.fenix.datastore.SelectedPocketStoriesCategories
+import org.mozilla.fenix.datastore.SelectedPocketStoriesCategories.SelectedPocketStoriesCategory
+import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory
+import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesSelectedCategory
+
+class PocketUpdatesMiddlewareTest {
+ @get:Rule
+ val mainCoroutineTestRule = MainCoroutineRule()
+
+ @Test
+ fun `WHEN PocketStoriesShown is dispatched THEN update PocketStoriesService`() = runTestOnMain {
+ val story1 = PocketRecommendedStory(
+ "title",
+ "url1",
+ "imageUrl",
+ "publisher",
+ "category",
+ 0,
+ timesShown = 0,
+ )
+ val story2 = story1.copy("title2", "url2")
+ val story3 = story1.copy("title3", "url3")
+ val pocketService: PocketStoriesService = mockk(relaxed = true)
+ val pocketMiddleware = PocketUpdatesMiddleware(pocketService, mockk(), this)
+ val appstore = AppStore(
+ AppState(
+ pocketStories = listOf(story1, story2, story3),
+ ),
+ listOf(pocketMiddleware),
+ )
+
+ appstore.dispatch(AppAction.PocketStoriesShown(listOf(story2))).joinBlocking()
+
+ coVerify { pocketService.updateStoriesTimesShown(listOf(story2.copy(timesShown = 1))) }
+ }
+
+ @Test
+ fun `WHEN needing to persist impressions is called THEN update PocketStoriesService`() = runTestOnMain {
+ val story = PocketRecommendedStory(
+ "title",
+ "url1",
+ "imageUrl",
+ "publisher",
+ "category",
+ 0,
+ timesShown = 3,
+ )
+ val stories = listOf(story)
+ val expectedStoryUpdate = story.copy(timesShown = story.timesShown.inc())
+ val pocketService: PocketStoriesService = mockk(relaxed = true)
+
+ persistStoriesImpressions(
+ coroutineScope = this,
+ pocketStoriesService = pocketService,
+ updatedStories = stories,
+ )
+
+ coVerify { pocketService.updateStoriesTimesShown(listOf(expectedStoryUpdate)) }
+ }
+
+ @Test
+ @Suppress("UNCHECKED_CAST")
+ fun `WHEN PocketStoriesCategoriesChange is dispatched THEN intercept and dispatch PocketStoriesCategoriesSelectionsChange`() = runTestOnMain {
+ val persistedSelectedCategory: SelectedPocketStoriesCategory = mockk {
+ every { name } returns "testCategory"
+ every { selectionTimestamp } returns 123
+ }
+ val persistedSelectedCategories: SelectedPocketStoriesCategories = mockk {
+ every { valuesList } returns mutableListOf(persistedSelectedCategory)
+ }
+ val dataStore: DataStore<SelectedPocketStoriesCategories> =
+ mockk<FakeDataStore<SelectedPocketStoriesCategories>>(relaxed = true) {
+ every { data } returns flowOf(persistedSelectedCategories)
+ } as DataStore<SelectedPocketStoriesCategories>
+ val currentCategories = listOf(mockk<PocketRecommendedStoriesCategory>())
+ val pocketMiddleware = PocketUpdatesMiddleware(mockk(), dataStore, this)
+ val appStore = spyk(
+ AppStore(
+ AppState(
+ pocketStoriesCategories = currentCategories,
+ ),
+ listOf(pocketMiddleware),
+ ),
+ )
+
+ appStore.dispatch(AppAction.PocketStoriesCategoriesChange(currentCategories)).joinBlocking()
+
+ verify {
+ appStore.dispatch(
+ AppAction.PocketStoriesCategoriesSelectionsChange(
+ storiesCategories = currentCategories,
+ categoriesSelected = listOf(
+ PocketRecommendedStoriesSelectedCategory("testCategory", 123),
+ ),
+ ),
+ )
+ }
+ }
+
+ @Test
+ @Suppress("UNCHECKED_CAST")
+ fun `WHEN SelectPocketStoriesCategory is dispatched THEN persist details in DataStore`() = runTestOnMain {
+ val categ1 = PocketRecommendedStoriesCategory("categ1")
+ val categ2 = PocketRecommendedStoriesCategory("categ2")
+ val dataStore: DataStore<SelectedPocketStoriesCategories> =
+ mockk<FakeDataStore<SelectedPocketStoriesCategories>>(relaxed = true) as
+ DataStore<SelectedPocketStoriesCategories>
+ val pocketMiddleware = PocketUpdatesMiddleware(mockk(), dataStore, this)
+ val appStore = spyk(
+ AppStore(
+ AppState(
+ pocketStoriesCategories = listOf(categ1, categ2),
+ ),
+ listOf(pocketMiddleware),
+ ),
+ )
+
+ appStore.dispatch(AppAction.SelectPocketStoriesCategory(categ2.name)).joinBlocking()
+
+ // Seems like the most we can test is that an update was made.
+ coVerify { dataStore.updateData(any()) }
+ }
+
+ @Test
+ @Suppress("UNCHECKED_CAST")
+ fun `WHEN DeselectPocketStoriesCategory is dispatched THEN persist details in DataStore`() = runTestOnMain {
+ val categ1 = PocketRecommendedStoriesCategory("categ1")
+ val categ2 = PocketRecommendedStoriesCategory("categ2")
+ val dataStore: DataStore<SelectedPocketStoriesCategories> =
+ mockk<FakeDataStore<SelectedPocketStoriesCategories>>(relaxed = true) as
+ DataStore<SelectedPocketStoriesCategories>
+ val pocketMiddleware = PocketUpdatesMiddleware(mockk(), dataStore, this)
+ val appStore = spyk(
+ AppStore(
+ AppState(
+ pocketStoriesCategories = listOf(categ1, categ2),
+ ),
+ listOf(pocketMiddleware),
+ ),
+ )
+
+ appStore.dispatch(AppAction.DeselectPocketStoriesCategory(categ2.name)).joinBlocking()
+
+ // Seems like the most we can test is that an update was made.
+ coVerify { dataStore.updateData(any()) }
+ }
+
+ @Test
+ @Suppress("UNCHECKED_CAST")
+ fun `WHEN persistCategories is called THEN update dataStore`() = runTestOnMain {
+ val dataStore: DataStore<SelectedPocketStoriesCategories> =
+ mockk<FakeDataStore<SelectedPocketStoriesCategories>>(relaxed = true) as
+ DataStore<SelectedPocketStoriesCategories>
+
+ persistSelectedCategories(this, listOf(mockk(relaxed = true)), dataStore)
+
+ // Seems like the most we can test is that an update was made.
+ coVerify { dataStore.updateData(any()) }
+ }
+
+ @Test
+ @Suppress("UNCHECKED_CAST")
+ fun `WHEN restoreSelectedCategories is called THEN dispatch PocketStoriesCategoriesSelectionsChange with data read from the persistence layer`() = runTestOnMain {
+ val persistedSelectedCategory: SelectedPocketStoriesCategory = mockk {
+ every { name } returns "testCategory"
+ every { selectionTimestamp } returns 123
+ }
+ val persistedSelectedCategories: SelectedPocketStoriesCategories = mockk {
+ every { valuesList } returns mutableListOf(persistedSelectedCategory)
+ }
+ val dataStore: DataStore<SelectedPocketStoriesCategories> =
+ mockk<FakeDataStore<SelectedPocketStoriesCategories>>(relaxed = true) {
+ every { data } returns flowOf(persistedSelectedCategories)
+ } as DataStore<SelectedPocketStoriesCategories>
+ val currentCategories = listOf(mockk<PocketRecommendedStoriesCategory>())
+ val appStore = spyk(
+ AppStore(AppState()),
+ )
+
+ restoreSelectedCategories(
+ coroutineScope = this,
+ currentCategories = currentCategories,
+ store = appStore,
+ selectedPocketCategoriesDataStore = dataStore,
+ )
+
+ coVerify {
+ appStore.dispatch(
+ AppAction.PocketStoriesCategoriesSelectionsChange(
+ storiesCategories = currentCategories,
+ categoriesSelected = listOf(
+ PocketRecommendedStoriesSelectedCategory("testCategory", 123),
+ ),
+ ),
+ )
+ }
+ }
+}
+
+/**
+ * Incomplete fake of a [DataStore].
+ * Respects the [DataStore] contract with basic method implementations but needs to have mocked behavior
+ * for more complex interactions.
+ * Can be used as a replacement for mocks of the [DataStore] interface which might fail intermittently.
+ */
+private class FakeDataStore<T> : DataStore<T?> {
+ override val data: Flow<T?>
+ get() = flow { }
+
+ override suspend fun updateData(transform: suspend (t: T?) -> T?): T? {
+ return transform(null)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/PrivateBrowsingButtonViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/PrivateBrowsingButtonViewTest.kt
new file mode 100644
index 0000000000..34e0fd0f88
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/PrivateBrowsingButtonViewTest.kt
@@ -0,0 +1,73 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home
+
+import android.view.View
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.R
+import org.mozilla.fenix.browser.browsingmode.BrowsingMode
+import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
+
+class PrivateBrowsingButtonViewTest {
+
+ private val enable = "Enable private browsing"
+ private val disable = "Disable private browsing"
+
+ private lateinit var button: View
+ private lateinit var browsingModeManager: BrowsingModeManager
+
+ @Before
+ fun setup() {
+ button = mockk(relaxed = true)
+ browsingModeManager = mockk(relaxed = true)
+
+ every { button.context.getString(R.string.content_description_private_browsing_button) } returns enable
+ every { button.context.getString(R.string.content_description_disable_private_browsing_button) } returns disable
+ every { browsingModeManager.mode } returns BrowsingMode.Normal
+ }
+
+ @Test
+ fun `constructor sets contentDescription and click listener`() {
+ val view = PrivateBrowsingButtonView(button, browsingModeManager) {}
+ verify { button.context.getString(R.string.content_description_private_browsing_button) }
+ verify { button.contentDescription = enable }
+ verify { button.setOnClickListener(view) }
+
+ every { browsingModeManager.mode } returns BrowsingMode.Private
+ val privateView = PrivateBrowsingButtonView(button, browsingModeManager) {}
+ verify { button.context.getString(R.string.content_description_disable_private_browsing_button) }
+ verify { button.contentDescription = disable }
+ verify { button.setOnClickListener(privateView) }
+ }
+
+ @Test
+ fun `click listener calls onClick with inverted mode from normal mode`() {
+ every { browsingModeManager.mode } returns BrowsingMode.Normal
+ var mode: BrowsingMode? = null
+ val view = PrivateBrowsingButtonView(button, browsingModeManager) { mode = it }
+
+ view.onClick(button)
+
+ assertEquals(BrowsingMode.Private, mode)
+ verify { browsingModeManager.mode = BrowsingMode.Private }
+ }
+
+ @Test
+ fun `click listener calls onClick with inverted mode from private mode`() {
+ every { browsingModeManager.mode } returns BrowsingMode.Private
+ var mode: BrowsingMode? = null
+ val view = PrivateBrowsingButtonView(button, browsingModeManager) { mode = it }
+
+ view.onClick(button)
+
+ assertEquals(BrowsingMode.Normal, mode)
+ verify { browsingModeManager.mode = BrowsingMode.Normal }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/RecentTabsListFeatureTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/RecentTabsListFeatureTest.kt
new file mode 100644
index 0000000000..9a29b53816
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/RecentTabsListFeatureTest.kt
@@ -0,0 +1,390 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home
+
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import mozilla.components.browser.state.action.ContentAction.UpdateIconAction
+import mozilla.components.browser.state.action.ContentAction.UpdateTitleAction
+import mozilla.components.browser.state.action.MediaSessionAction
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.LastMediaAccessState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.mediasession.MediaSession
+import mozilla.components.feature.media.middleware.LastMediaAccessMiddleware
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.middleware.CaptureActionsMiddleware
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Ignore
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppAction
+import org.mozilla.fenix.components.appstate.AppState
+import org.mozilla.fenix.home.recenttabs.RecentTab
+import org.mozilla.fenix.home.recenttabs.RecentTabsListFeature
+
+class RecentTabsListFeatureTest {
+
+ private lateinit var appStore: AppStore
+ private lateinit var middleware: CaptureActionsMiddleware<AppState, AppAction>
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Before
+ fun setup() {
+ middleware = CaptureActionsMiddleware()
+ appStore = AppStore(middlewares = listOf(middleware))
+ }
+
+ @After
+ fun teardown() {
+ middleware.reset()
+ }
+
+ @Test
+ fun `GIVEN no selected, last active or in progress media tab WHEN the feature starts THEN dispatch an empty list`() {
+ val browserStore = BrowserStore()
+ val appStore = AppStore()
+ val feature = RecentTabsListFeature(
+ browserStore = browserStore,
+ appStore = appStore,
+ )
+
+ feature.start()
+
+ appStore.waitUntilIdle()
+
+ assertEquals(0, appStore.state.recentTabs.size)
+ }
+
+ @Test
+ fun `GIVEN no selected but last active tab available WHEN the feature starts THEN dispatch the last active tab as a recent tab list`() {
+ val tab = createTab(
+ url = "https://www.mozilla.org",
+ id = "1",
+ )
+ val tabs = listOf(tab)
+ val browserStore = BrowserStore(
+ BrowserState(tabs = tabs),
+ )
+ val feature = RecentTabsListFeature(
+ browserStore = browserStore,
+ appStore = appStore,
+ )
+
+ feature.start()
+
+ appStore.waitUntilIdle()
+
+ assertEquals(1, appStore.state.recentTabs.size)
+ }
+
+ @Test
+ fun `GIVEN a selected tab WHEN the feature starts THEN dispatch the selected tab as a recent tab list`() {
+ val tab = createTab(
+ url = "https://www.mozilla.org",
+ id = "1",
+ )
+ val tabs = listOf(tab)
+ val browserStore = BrowserStore(
+ BrowserState(
+ tabs = tabs,
+ selectedTabId = "1",
+ ),
+ )
+ val feature = RecentTabsListFeature(
+ browserStore = browserStore,
+ appStore = appStore,
+ )
+
+ feature.start()
+
+ appStore.waitUntilIdle()
+
+ assertEquals(1, appStore.state.recentTabs.size)
+ }
+
+ @Ignore("Disabled until we want to enable this feature. See #21670.")
+ @Test
+ fun `GIVEN a valid inProgressMediaTabId and another selected tab exists WHEN the feature starts THEN dispatch both as as a recent tabs list`() {
+ val mediaTab = createTab(
+ url = "https://mozilla.com",
+ id = "42",
+ lastMediaAccessState = LastMediaAccessState("https://mozilla.com", 123),
+ )
+ val selectedTab = createTab("https://mozilla.com", id = "43")
+ val browserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(mediaTab, selectedTab),
+ selectedTabId = "43",
+ ),
+ )
+ val feature = RecentTabsListFeature(
+ browserStore = browserStore,
+ appStore = appStore,
+ )
+
+ feature.start()
+ appStore.waitUntilIdle()
+
+ assertEquals(2, appStore.state.recentTabs.size)
+ assertTrue(appStore.state.recentTabs[0] is RecentTab.Tab)
+ assertEquals(selectedTab, (appStore.state.recentTabs[0] as RecentTab.Tab).state)
+ assertTrue(appStore.state.recentTabs[1] is RecentTab.Tab)
+ assertEquals(mediaTab, (appStore.state.recentTabs[1] as RecentTab.Tab).state)
+ }
+
+ @Ignore("Disabled until we want to enable this feature. See #21670.")
+ @Test
+ fun `GIVEN a valid inProgressMediaTabId exists and that is the selected tab WHEN the feature starts THEN dispatch just one tab as the recent tabs list`() {
+ val selectedMediaTab = createTab(
+ "https://mozilla.com",
+ id = "42",
+ lastMediaAccessState = LastMediaAccessState("https://mozilla.com", 123),
+ )
+ val browserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(selectedMediaTab),
+ selectedTabId = "42",
+ ),
+ )
+ val feature = RecentTabsListFeature(
+ browserStore = browserStore,
+ appStore = appStore,
+ )
+
+ feature.start()
+ appStore.waitUntilIdle()
+
+ assertEquals(1, appStore.state.recentTabs.size)
+ assertTrue(appStore.state.recentTabs[0] is RecentTab.Tab)
+ assertEquals(selectedMediaTab, (appStore.state.recentTabs[0] as RecentTab.Tab).state)
+ }
+
+ @Test
+ fun `WHEN the browser state has an updated select tab THEN dispatch the new recent tab list`() {
+ val tab1 = createTab(
+ url = "https://www.mozilla.org",
+ id = "1",
+ )
+ val tab2 = createTab(
+ url = "https://www.firefox.com",
+ id = "2",
+ )
+ val tabs = listOf(tab1, tab2)
+ val browserStore = BrowserStore(
+ BrowserState(
+ tabs = tabs,
+ selectedTabId = "1",
+ ),
+ )
+ val feature = RecentTabsListFeature(
+ browserStore = browserStore,
+ appStore = appStore,
+ )
+
+ feature.start()
+
+ appStore.waitUntilIdle()
+
+ assertEquals(1, appStore.state.recentTabs.size)
+ assertTrue(appStore.state.recentTabs[0] is RecentTab.Tab)
+ assertEquals(tab1, (appStore.state.recentTabs[0] as RecentTab.Tab).state)
+
+ browserStore.dispatch(TabListAction.SelectTabAction(tab2.id)).joinBlocking()
+
+ appStore.waitUntilIdle()
+
+ assertEquals(1, appStore.state.recentTabs.size)
+ assertTrue(appStore.state.recentTabs[0] is RecentTab.Tab)
+ assertEquals(tab2, (appStore.state.recentTabs[0] as RecentTab.Tab).state)
+ }
+
+ @Ignore("Disabled until we want to enable this feature. See #21670.")
+ @Test
+ fun `WHEN the browser state has an in progress media tab THEN dispatch the new recent tab list`() {
+ val initialMediaTab = createTab(
+ url = "https://mozilla.com",
+ id = "1",
+ lastMediaAccessState = LastMediaAccessState("https://mozilla.com", 123),
+ )
+ val newMediaTab = createTab(
+ url = "http://mozilla.org",
+ id = "2",
+ lastMediaAccessState = LastMediaAccessState("https://mozilla.com", 100),
+ )
+ val browserStore = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(initialMediaTab, newMediaTab),
+ selectedTabId = "1",
+ ),
+ middleware = listOf(LastMediaAccessMiddleware()),
+ )
+ val feature = RecentTabsListFeature(
+ browserStore = browserStore,
+ appStore = appStore,
+ )
+
+ feature.start()
+ appStore.waitUntilIdle()
+ assertEquals(2, appStore.state.recentTabs.size)
+ assertTrue(appStore.state.recentTabs[0] is RecentTab.Tab)
+ assertEquals(initialMediaTab, (appStore.state.recentTabs[0] as RecentTab.Tab).state)
+
+ browserStore.dispatch(
+ MediaSessionAction.UpdateMediaPlaybackStateAction("2", MediaSession.PlaybackState.PLAYING),
+ ).joinBlocking()
+ appStore.waitUntilIdle()
+ assertEquals(2, appStore.state.recentTabs.size)
+ assertTrue(appStore.state.recentTabs[0] is RecentTab.Tab)
+ assertEquals(initialMediaTab, (appStore.state.recentTabs[0] as RecentTab.Tab).state)
+ // UpdateMediaPlaybackStateAction would set the current timestamp as the new value for lastMediaAccess
+ val updatedLastMediaAccess =
+ (appStore.state.recentTabs[1] as RecentTab.Tab).state.lastMediaAccessState.lastMediaAccess
+ assertTrue("expected lastMediaAccess ($updatedLastMediaAccess) > 100", updatedLastMediaAccess > 100)
+ assertEquals(
+ "http://mozilla.org",
+ (appStore.state.recentTabs[1] as RecentTab.Tab).state.lastMediaAccessState.lastMediaUrl,
+ )
+ // Check that the media tab is updated ignoring just the lastMediaAccess property.
+ assertEquals(
+ newMediaTab,
+ (appStore.state.recentTabs[1] as RecentTab.Tab).state.copy(
+ lastMediaAccessState = LastMediaAccessState("https://mozilla.com", 100),
+ ),
+ )
+ }
+
+ @Test
+ fun `WHEN the browser state selects a private tab THEN dispatch an empty list`() {
+ val selectedNormalTab = createTab(
+ url = "https://www.mozilla.org",
+ id = "1",
+ lastAccess = 0,
+ )
+ val lastAccessedNormalTab = createTab(
+ url = "https://www.mozilla.org",
+ id = "2",
+ lastAccess = 1,
+ )
+ val privateTab = createTab(
+ url = "https://www.firefox.com",
+ id = "3",
+ private = true,
+ )
+ val tabs = listOf(selectedNormalTab, lastAccessedNormalTab, privateTab)
+ val browserStore = BrowserStore(
+ BrowserState(
+ tabs = tabs,
+ selectedTabId = "1",
+ ),
+ )
+ val feature = RecentTabsListFeature(
+ browserStore = browserStore,
+ appStore = appStore,
+ )
+
+ feature.start()
+
+ appStore.waitUntilIdle()
+
+ assertEquals(1, appStore.state.recentTabs.size)
+ assertTrue(appStore.state.recentTabs[0] is RecentTab.Tab)
+ assertEquals(selectedNormalTab, (appStore.state.recentTabs[0] as RecentTab.Tab).state)
+
+ browserStore.dispatch(TabListAction.SelectTabAction(privateTab.id)).joinBlocking()
+
+ appStore.waitUntilIdle()
+
+ // If the selected tab is a private tab the feature should show the last accessed normal tab.
+ assertEquals(1, appStore.state.recentTabs.size)
+ assertTrue(appStore.state.recentTabs[0] is RecentTab.Tab)
+ assertEquals(lastAccessedNormalTab, (appStore.state.recentTabs[0] as RecentTab.Tab).state)
+ }
+
+ @Test
+ fun `WHEN the selected tabs title or icon update THEN update the home store`() {
+ val browserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab(
+ url = "https://www.mozilla.org",
+ id = "1",
+ ),
+ ),
+ selectedTabId = "1",
+ ),
+ )
+ val feature = RecentTabsListFeature(
+ browserStore = browserStore,
+ appStore = appStore,
+ )
+
+ feature.start()
+
+ appStore.waitUntilIdle()
+
+ middleware.assertLastAction(AppAction.RecentTabsChange::class) {
+ val tab = it.recentTabs.first() as RecentTab.Tab
+ assertTrue(tab.state.content.title.isEmpty())
+ assertNull(tab.state.content.icon)
+ }
+
+ browserStore.dispatch(UpdateTitleAction("1", "test")).joinBlocking()
+
+ appStore.waitUntilIdle()
+
+ middleware.assertLastAction(AppAction.RecentTabsChange::class) {
+ val tab = it.recentTabs.first() as RecentTab.Tab
+ assertEquals("test", tab.state.content.title)
+ assertNull(tab.state.content.icon)
+ }
+
+ browserStore.dispatch(UpdateIconAction("1", "https://www.mozilla.org", mockk()))
+ .joinBlocking()
+
+ appStore.waitUntilIdle()
+
+ middleware.assertLastAction(AppAction.RecentTabsChange::class) {
+ val tab = it.recentTabs.first() as RecentTab.Tab
+ assertEquals("test", tab.state.content.title)
+ assertNotNull(tab.state.content.icon)
+ }
+ }
+
+ @Test
+ fun `GIVEN inProgressMediaTab already set WHEN the media tab is closed THEN remove it from recent tabs`() {
+ val initialMediaTab = createTab(url = "https://mozilla.com", id = "1")
+ val selectedTab = createTab(url = "https://mozilla.com/firefox", id = "2")
+ val browserStore = BrowserStore(
+ initialState = BrowserState(listOf(initialMediaTab, selectedTab), selectedTabId = "2"),
+ middleware = listOf(LastMediaAccessMiddleware()),
+ )
+ val feature = RecentTabsListFeature(
+ browserStore = browserStore,
+ appStore = appStore,
+ )
+
+ feature.start()
+ browserStore.dispatch(TabListAction.RemoveTabsAction(listOf("1"))).joinBlocking()
+ appStore.waitUntilIdle()
+
+ assertEquals(1, appStore.state.recentTabs.size)
+ assertTrue(appStore.state.recentTabs[0] is RecentTab.Tab)
+ assertEquals(selectedTab, (appStore.state.recentTabs[0] as RecentTab.Tab).state)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/SessionControlInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/SessionControlInteractorTest.kt
new file mode 100644
index 0000000000..78cc69b01b
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/SessionControlInteractorTest.kt
@@ -0,0 +1,307 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.feature.tab.collections.Tab
+import mozilla.components.feature.tab.collections.TabCollection
+import mozilla.components.feature.top.sites.TopSite
+import mozilla.components.service.pocket.PocketStory
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.browser.browsingmode.BrowsingMode
+import org.mozilla.fenix.components.appstate.AppState
+import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory
+import org.mozilla.fenix.home.pocket.PocketStoriesController
+import org.mozilla.fenix.home.privatebrowsing.controller.PrivateBrowsingController
+import org.mozilla.fenix.home.recentbookmarks.RecentBookmark
+import org.mozilla.fenix.home.recentbookmarks.controller.RecentBookmarksController
+import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTab
+import org.mozilla.fenix.home.recentsyncedtabs.controller.RecentSyncedTabController
+import org.mozilla.fenix.home.recenttabs.controller.RecentTabController
+import org.mozilla.fenix.home.recentvisits.controller.RecentVisitsController
+import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController
+import org.mozilla.fenix.home.sessioncontrol.SessionControlInteractor
+import org.mozilla.fenix.home.toolbar.ToolbarController
+import org.mozilla.fenix.search.ExtraAction
+import org.mozilla.fenix.search.toolbar.SearchSelectorController
+
+class SessionControlInteractorTest {
+
+ private val controller: DefaultSessionControlController = mockk(relaxed = true)
+ private val recentTabController: RecentTabController = mockk(relaxed = true)
+ private val recentSyncedTabController: RecentSyncedTabController = mockk(relaxed = true)
+ private val recentBookmarksController: RecentBookmarksController = mockk(relaxed = true)
+ private val pocketStoriesController: PocketStoriesController = mockk(relaxed = true)
+ private val privateBrowsingController: PrivateBrowsingController = mockk(relaxed = true)
+ private val searchSelectorController: SearchSelectorController = mockk(relaxed = true)
+ private val toolbarController: ToolbarController = mockk(relaxed = true)
+
+ // Note: the recent visits tests are handled in [RecentVisitsInteractorTest] and [RecentVisitsControllerTest]
+ private val recentVisitsController: RecentVisitsController = mockk(relaxed = true)
+
+ private lateinit var interactor: SessionControlInteractor
+
+ @Before
+ fun setup() {
+ interactor = SessionControlInteractor(
+ controller,
+ recentTabController,
+ recentSyncedTabController,
+ recentBookmarksController,
+ recentVisitsController,
+ pocketStoriesController,
+ privateBrowsingController,
+ searchSelectorController,
+ toolbarController,
+ )
+ }
+
+ @Test
+ fun onCollectionAddTabTapped() {
+ val collection: TabCollection = mockk(relaxed = true)
+ interactor.onCollectionAddTabTapped(collection)
+ verify { controller.handleCollectionAddTabTapped(collection) }
+ }
+
+ @Test
+ fun onCollectionOpenTabClicked() {
+ val tab: Tab = mockk(relaxed = true)
+ interactor.onCollectionOpenTabClicked(tab)
+ verify { controller.handleCollectionOpenTabClicked(tab) }
+ }
+
+ @Test
+ fun onCollectionOpenTabsTapped() {
+ val collection: TabCollection = mockk(relaxed = true)
+ interactor.onCollectionOpenTabsTapped(collection)
+ verify { controller.handleCollectionOpenTabsTapped(collection) }
+ }
+
+ @Test
+ fun onCollectionRemoveTab() {
+ val collection: TabCollection = mockk(relaxed = true)
+ val tab: Tab = mockk(relaxed = true)
+ interactor.onCollectionRemoveTab(collection, tab)
+ verify { controller.handleCollectionRemoveTab(collection, tab) }
+ }
+
+ @Test
+ fun onCollectionShareTabsClicked() {
+ val collection: TabCollection = mockk(relaxed = true)
+ interactor.onCollectionShareTabsClicked(collection)
+ verify { controller.handleCollectionShareTabsClicked(collection) }
+ }
+
+ @Test
+ fun onDeleteCollectionTapped() {
+ val collection: TabCollection = mockk(relaxed = true)
+ interactor.onDeleteCollectionTapped(collection)
+ verify { controller.handleDeleteCollectionTapped(collection) }
+ }
+
+ @Test
+ fun onPrivateBrowsingLearnMoreClicked() {
+ interactor.onLearnMoreClicked()
+ verify { privateBrowsingController.handleLearnMoreClicked() }
+ }
+
+ @Test
+ fun onRenameCollectionTapped() {
+ val collection: TabCollection = mockk(relaxed = true)
+ interactor.onRenameCollectionTapped(collection)
+ verify { controller.handleRenameCollectionTapped(collection) }
+ }
+
+ @Test
+ fun onToggleCollectionExpanded() {
+ val collection: TabCollection = mockk(relaxed = true)
+ interactor.onToggleCollectionExpanded(collection, true)
+ verify { controller.handleToggleCollectionExpanded(collection, true) }
+ }
+
+ @Test
+ fun onAddTabsToCollection() {
+ interactor.onAddTabsToCollectionTapped()
+ verify { controller.handleCreateCollection() }
+ }
+
+ @Test
+ fun onPaste() {
+ interactor.onPaste("text")
+ verify { toolbarController.handlePaste("text") }
+ }
+
+ @Test
+ fun onPasteAndGo() {
+ interactor.onPasteAndGo("text")
+ verify { toolbarController.handlePasteAndGo("text") }
+ }
+
+ @Test
+ fun onNavigateSearch() {
+ interactor.onNavigateSearch()
+ verify { toolbarController.handleNavigateSearch() }
+ }
+
+ @Test
+ fun onNavigateSearchWithQr() {
+ interactor.onNavigateSearch(ExtraAction.QR_READER)
+ verify { toolbarController.handleNavigateSearch(ExtraAction.QR_READER) }
+ }
+
+ @Test
+ fun onNavigateSearchWithVoice() {
+ interactor.onNavigateSearch(ExtraAction.VOICE_SEARCH)
+ verify { toolbarController.handleNavigateSearch(ExtraAction.VOICE_SEARCH) }
+ }
+
+ @Test
+ fun onRemoveCollectionsPlaceholder() {
+ interactor.onRemoveCollectionsPlaceholder()
+ verify { controller.handleRemoveCollectionsPlaceholder() }
+ }
+
+ @Test
+ fun onRecentTabClicked() {
+ val tabId = "tabId"
+ interactor.onRecentTabClicked(tabId)
+ verify { recentTabController.handleRecentTabClicked(tabId) }
+ }
+
+ @Test
+ fun onRecentTabShowAllClicked() {
+ interactor.onRecentTabShowAllClicked()
+ verify { recentTabController.handleRecentTabShowAllClicked() }
+ }
+
+ @Test
+ fun `WHEN recent synced tab is clicked THEN the tab is handled`() {
+ val tab: RecentSyncedTab = mockk()
+ interactor.onRecentSyncedTabClicked(tab)
+
+ verify { recentSyncedTabController.handleRecentSyncedTabClick(tab) }
+ }
+
+ @Test
+ fun `WHEN recent synced tabs show all is clicked THEN show all synced tabs is handled`() {
+ interactor.onSyncedTabShowAllClicked()
+
+ verify { recentSyncedTabController.handleSyncedTabShowAllClicked() }
+ }
+
+ @Test
+ fun `WHEN a recently saved bookmark is clicked THEN the selected bookmark is handled`() {
+ val bookmark = RecentBookmark()
+
+ interactor.onRecentBookmarkClicked(bookmark)
+ verify { recentBookmarksController.handleBookmarkClicked(bookmark) }
+ }
+
+ @Test
+ fun `WHEN tapping on the customize home button THEN openCustomizeHomePage`() {
+ interactor.openCustomizeHomePage()
+ verify { controller.handleCustomizeHomeTapped() }
+ }
+
+ @Test
+ fun `WHEN Show All recently saved bookmarks button is clicked THEN the click is handled`() {
+ interactor.onShowAllBookmarksClicked()
+ verify { recentBookmarksController.handleShowAllBookmarksClicked() }
+ }
+
+ @Test
+ fun `WHEN private mode button is clicked THEN the click is handled`() {
+ val newMode = BrowsingMode.Private
+
+ interactor.onPrivateModeButtonClicked(newMode)
+ verify { privateBrowsingController.handlePrivateModeButtonClicked(newMode) }
+ }
+
+ @Test
+ fun `WHEN onSettingsClicked is called THEN handleTopSiteSettingsClicked is called`() {
+ interactor.onSettingsClicked()
+ verify { controller.handleTopSiteSettingsClicked() }
+ }
+
+ @Test
+ fun `WHEN onSponsorPrivacyClicked is called THEN handleSponsorPrivacyClicked is called`() {
+ interactor.onSponsorPrivacyClicked()
+ verify { controller.handleSponsorPrivacyClicked() }
+ }
+
+ @Test
+ fun `WHEN a top site is long clicked THEN the click is handled`() {
+ val topSite: TopSite = mockk()
+ interactor.onTopSiteLongClicked(topSite)
+ verify { controller.handleTopSiteLongClicked(topSite) }
+ }
+
+ @Test
+ fun `GIVEN a PocketStoriesInteractor WHEN a story is shown THEN handle it in a PocketStoriesController`() {
+ val shownStory: PocketStory = mockk()
+ val storyGridLocation = 1 to 2
+
+ interactor.onStoryShown(shownStory, storyGridLocation)
+
+ verify { pocketStoriesController.handleStoryShown(shownStory, storyGridLocation) }
+ }
+
+ @Test
+ fun `GIVEN a PocketStoriesInteractor WHEN stories are shown THEN handle it in a PocketStoriesController`() {
+ val shownStories: List<PocketStory> = mockk()
+
+ interactor.onStoriesShown(shownStories)
+
+ verify { pocketStoriesController.handleStoriesShown(shownStories) }
+ }
+
+ @Test
+ fun `GIVEN a PocketStoriesInteractor WHEN a category is clicked THEN handle it in a PocketStoriesController`() {
+ val clickedCategory: PocketRecommendedStoriesCategory = mockk()
+
+ interactor.onCategoryClicked(clickedCategory)
+
+ verify { pocketStoriesController.handleCategoryClick(clickedCategory) }
+ }
+
+ @Test
+ fun `GIVEN a PocketStoriesInteractor WHEN a story is clicked THEN handle it in a PocketStoriesController`() {
+ val clickedStory: PocketStory = mockk()
+ val storyGridLocation = 1 to 2
+
+ interactor.onStoryClicked(clickedStory, storyGridLocation)
+
+ verify { pocketStoriesController.handleStoryClicked(clickedStory, storyGridLocation) }
+ }
+
+ @Test
+ fun `GIVEN a PocketStoriesInteractor WHEN discover more clicked THEN handle it in a PocketStoriesController`() {
+ val link = "http://getpocket.com/explore"
+
+ interactor.onDiscoverMoreClicked(link)
+
+ verify { pocketStoriesController.handleDiscoverMoreClicked(link) }
+ }
+
+ @Test
+ fun `GIVEN a PocketStoriesInteractor WHEN learn more clicked THEN handle it in a PocketStoriesController`() {
+ val link = "https://www.mozilla.org/en-US/firefox/pocket/"
+
+ interactor.onLearnMoreClicked(link)
+
+ verify { pocketStoriesController.handleLearnMoreClicked(link) }
+ }
+
+ @Test
+ fun reportSessionMetrics() {
+ val appState: AppState = mockk(relaxed = true)
+ every { appState.recentBookmarks } returns emptyList()
+ interactor.reportSessionMetrics(appState)
+ verify { controller.handleReportSessionMetrics(appState) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/TabCounterViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/TabCounterViewTest.kt
new file mode 100644
index 0000000000..14f176c8e4
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/TabCounterViewTest.kt
@@ -0,0 +1,174 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home
+
+import androidx.navigation.NavController
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import io.mockk.verifyOrder
+import mozilla.components.browser.state.selector.normalTabs
+import mozilla.components.browser.state.selector.privateTabs
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.ui.tabcounter.TabCounter
+import mozilla.components.ui.tabcounter.TabCounterMenu
+import mozilla.telemetry.glean.testing.GleanTestRule
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.StartOnHome
+import org.mozilla.fenix.NavGraphDirections
+import org.mozilla.fenix.R
+import org.mozilla.fenix.browser.browsingmode.BrowsingMode
+import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
+import org.mozilla.fenix.browser.browsingmode.DefaultBrowsingModeManager
+import org.mozilla.fenix.ext.nav
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class TabCounterViewTest {
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ private lateinit var navController: NavController
+ private lateinit var browsingModeManager: BrowsingModeManager
+ private lateinit var settings: Settings
+ private lateinit var modeDidChange: (BrowsingMode) -> Unit
+ private lateinit var tabCounterView: TabCounterView
+ private lateinit var tabCounter: TabCounter
+
+ @Before
+ fun setup() {
+ navController = mockk(relaxed = true)
+ settings = mockk(relaxed = true)
+ modeDidChange = mockk(relaxed = true)
+
+ tabCounter = spyk(TabCounter(testContext))
+
+ browsingModeManager = DefaultBrowsingModeManager(
+ _mode = BrowsingMode.Normal,
+ settings = settings,
+ modeDidChange = modeDidChange,
+ )
+
+ tabCounterView = TabCounterView(
+ context = testContext,
+ browsingModeManager = browsingModeManager,
+ navController = navController,
+ tabCounter = tabCounter,
+ )
+ }
+
+ @Test
+ fun `WHEN tab counter is clicked THEN navigate to tabs tray and record metrics`() {
+ every { navController.currentDestination?.id } returns R.id.homeFragment
+
+ assertNull(StartOnHome.openTabsTray.testGetValue())
+
+ tabCounter.performClick()
+
+ assertNotNull(StartOnHome.openTabsTray.testGetValue())
+
+ verify {
+ navController.nav(
+ R.id.homeFragment,
+ NavGraphDirections.actionGlobalTabsTrayFragment(),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN New tab menu item is tapped THEN set browsing mode to normal`() {
+ tabCounterView.onItemTapped(TabCounterMenu.Item.NewTab)
+
+ assertEquals(BrowsingMode.Normal, browsingModeManager.mode)
+ }
+
+ @Test
+ fun `WHEN New private tab menu item is tapped THEN set browsing mode to private`() {
+ tabCounterView.onItemTapped(TabCounterMenu.Item.NewPrivateTab)
+
+ assertEquals(BrowsingMode.Private, browsingModeManager.mode)
+ }
+
+ @Test
+ fun `WHEN tab counter is updated THEN set the tab counter to the correct number of tabs`() {
+ every { testContext.settings() } returns settings
+
+ val browserState = BrowserState(
+ tabs = listOf(
+ createTab(url = "https://www.mozilla.org", id = "mozilla"),
+ createTab(url = "https://www.firefox.com", id = "firefox"),
+ createTab(url = "https://getpocket.com", private = true, id = "getpocket"),
+ ),
+ selectedTabId = "mozilla",
+ )
+
+ tabCounterView.update(browserState)
+
+ verify {
+ tabCounter.setCountWithAnimation(browserState.normalTabs.size)
+ }
+
+ browsingModeManager.mode = BrowsingMode.Private
+
+ tabCounterView.update(browserState)
+
+ verify {
+ tabCounter.setCountWithAnimation(browserState.privateTabs.size)
+ }
+ }
+
+ @Test
+ fun `WHEN state updated while in private mode THEN call toggleCounterMask(true)`() {
+ every { settings.feltPrivateBrowsingEnabled } returns true
+ every { testContext.settings() } returns settings
+ val browserState = BrowserState(
+ tabs = listOf(
+ createTab(url = "https://www.mozilla.org", id = "mozilla"),
+ createTab(url = "https://www.firefox.com", id = "firefox"),
+ createTab(url = "https://getpocket.com", private = true, id = "getpocket"),
+ ),
+ selectedTabId = "mozilla",
+ )
+
+ browsingModeManager.mode = BrowsingMode.Private
+ tabCounterView.update(browserState)
+
+ verifyOrder {
+ tabCounter.toggleCounterMask(true)
+ }
+ }
+
+ @Test
+ fun `WHEN state updated while in normal mode THEN call toggleCounterMask(false)`() {
+ every { settings.feltPrivateBrowsingEnabled } returns true
+ every { testContext.settings() } returns settings
+ val browserState = BrowserState(
+ tabs = listOf(
+ createTab(url = "https://www.mozilla.org", id = "mozilla"),
+ createTab(url = "https://www.firefox.com", id = "firefox"),
+ createTab(url = "https://getpocket.com", private = true, id = "getpocket"),
+ ),
+ selectedTabId = "mozilla",
+ )
+
+ tabCounterView.update(browserState)
+
+ verifyOrder {
+ tabCounter.toggleCounterMask(false)
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/blocklist/BlocklistHandlerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/blocklist/BlocklistHandlerTest.kt
new file mode 100644
index 0000000000..c41002088e
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/blocklist/BlocklistHandlerTest.kt
@@ -0,0 +1,191 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.blocklist
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import mozilla.components.browser.state.state.ContentState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.concept.sync.DeviceType
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.home.recentbookmarks.RecentBookmark
+import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTab
+import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTabState
+import org.mozilla.fenix.home.recenttabs.RecentTab
+import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class BlocklistHandlerTest {
+ private val mockSettings: Settings = mockk()
+
+ private lateinit var blocklistHandler: BlocklistHandler
+
+ @Before
+ fun setup() {
+ blocklistHandler = BlocklistHandler(mockSettings)
+ }
+
+ @Test
+ fun `WHEN url added to blocklist THEN settings updated with hash`() {
+ val addedUrl = "url"
+ val updateSlot = slot<Set<String>>()
+ every { mockSettings.homescreenBlocklist } returns setOf()
+ every { mockSettings.homescreenBlocklist = capture(updateSlot) } returns Unit
+
+ blocklistHandler.addUrlToBlocklist(addedUrl)
+
+ assertEquals(setOf(addedUrl.stripAndHash()), updateSlot.captured)
+ }
+
+ @Test
+ fun `GIVEN bookmark is not in blocklist THEN will not be filtered`() {
+ val bookmarks = listOf(RecentBookmark(url = "test"))
+ every { mockSettings.homescreenBlocklist } returns setOf()
+
+ val filtered = with(blocklistHandler) {
+ bookmarks.filteredByBlocklist()
+ }
+
+ assertEquals(bookmarks, filtered)
+ }
+
+ @Test
+ fun `GIVEN bookmark is in blocklist THEN will be filtered`() {
+ val blockedUrl = "test"
+ val bookmarks = listOf(RecentBookmark(url = blockedUrl))
+ every { mockSettings.homescreenBlocklist } returns setOf(blockedUrl.stripAndHash())
+
+ val filtered = with(blocklistHandler) {
+ bookmarks.filteredByBlocklist()
+ }
+
+ assertEquals(listOf<String>(), filtered)
+ }
+
+ @Test
+ fun `GIVEN recent history is not in blocklist THEN will not be filtered`() {
+ val recentHistory = listOf(RecentlyVisitedItem.RecentHistoryHighlight(url = "test", title = ""))
+ every { mockSettings.homescreenBlocklist } returns setOf()
+
+ val filtered = with(blocklistHandler) {
+ recentHistory.filteredByBlocklist()
+ }
+
+ assertEquals(recentHistory, filtered)
+ }
+
+ @Test
+ fun `GIVEN recent history is in blocklist THEN will be filtered`() {
+ val blockedUrl = "test"
+ val recentHistory = listOf(RecentlyVisitedItem.RecentHistoryHighlight(url = blockedUrl, title = ""))
+ every { mockSettings.homescreenBlocklist } returns setOf(blockedUrl.stripAndHash())
+
+ val filtered = with(blocklistHandler) {
+ recentHistory.filteredByBlocklist()
+ }
+
+ assertEquals(listOf<String>(), filtered)
+ }
+
+ @Test
+ fun `GIVEN recent tab is not in blocklist THEN will not be filtered`() {
+ val mockSessionState: TabSessionState = mockk()
+ val mockContent: ContentState = mockk()
+ val tabs = listOf(RecentTab.Tab(mockSessionState))
+ every { mockSessionState.content } returns mockContent
+ every { mockContent.url } returns "test"
+ every { mockSettings.homescreenBlocklist } returns setOf()
+
+ val filtered = with(blocklistHandler) {
+ tabs.filteredByBlocklist()
+ }
+
+ assertEquals(tabs, filtered)
+ }
+
+ @Test
+ fun `GIVEN recent tab is in blocklist THEN will be filtered`() {
+ val blockedUrl = "test"
+ val mockSessionState: TabSessionState = mockk()
+ val mockContent: ContentState = mockk()
+ val tabs = listOf(RecentTab.Tab(mockSessionState))
+ every { mockSessionState.content } returns mockContent
+ every { mockContent.url } returns blockedUrl
+ every { mockSettings.homescreenBlocklist } returns setOf(blockedUrl.stripAndHash())
+
+ val filtered = with(blocklistHandler) {
+ tabs.filteredByBlocklist()
+ }
+
+ assertEquals(listOf<String>(), filtered)
+ }
+
+ @Test
+ fun `GIVEN recently synced tab is a sponsored url WHEN the tabs are filtered THEN the sponsored url be filtered`() {
+ val blockedUrl = "test.com/?query=value"
+ val mockSessionState: TabSessionState = mockk()
+ val mockContent: ContentState = mockk()
+ val tabs = RecentSyncedTabState.Success(
+ listOf(
+ RecentSyncedTab(
+ "",
+ DeviceType.DESKTOP,
+ "title",
+ blockedUrl,
+ null,
+ ),
+ ),
+ )
+ every { mockSessionState.content } returns mockContent
+ every { mockContent.url } returns blockedUrl
+ every { mockSettings.frecencyFilterQuery } returns "query=value"
+
+ val filtered = with(blocklistHandler) {
+ tabs.filterContile()
+ }
+
+ assertEquals(RecentSyncedTabState.None, filtered)
+ }
+
+ @Test
+ fun `GIVEN recently visited item is a sponsored url WHEN the tabs are filtered THEN the sponsored url be filtered`() {
+ val blockedUrl = "test.com/?query=value"
+ val mockSessionState: TabSessionState = mockk()
+ val mockContent: ContentState = mockk()
+ val tabs = listOf(RecentlyVisitedItem.RecentHistoryHighlight("title", blockedUrl))
+ every { mockSessionState.content } returns mockContent
+ every { mockContent.url } returns blockedUrl
+ every { mockSettings.frecencyFilterQuery } returns "query=value"
+
+ val filtered = with(blocklistHandler) {
+ tabs.filterContile()
+ }
+
+ assertEquals(listOf<String>(), filtered)
+ }
+
+ @Test
+ fun `GIVEN recent tab is a sponsored url WHEN the tabs are filtered THEN the sponsored url be filtered`() {
+ val blockedUrl = "test.com/?query=value"
+ val mockSessionState: TabSessionState = mockk()
+ val mockContent: ContentState = mockk()
+ val tabs = listOf(RecentTab.Tab(mockSessionState))
+ every { mockSessionState.content } returns mockContent
+ every { mockContent.url } returns blockedUrl
+ every { mockSettings.frecencyFilterQuery } returns "query=value"
+
+ val filtered = with(blocklistHandler) {
+ tabs.filterContile()
+ }
+
+ assertEquals(listOf<String>(), filtered)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/blocklist/BlocklistMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/blocklist/BlocklistMiddlewareTest.kt
new file mode 100644
index 0000000000..3339df1ee9
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/blocklist/BlocklistMiddlewareTest.kt
@@ -0,0 +1,462 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.blocklist
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.middleware.CaptureActionsMiddleware
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppAction
+import org.mozilla.fenix.components.appstate.AppState
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.home.recentbookmarks.RecentBookmark
+import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTab
+import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTabState
+import org.mozilla.fenix.home.recenttabs.RecentTab
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class BlocklistMiddlewareTest {
+ private val mockSettings: Settings = mockk()
+ private val blocklistHandler = BlocklistHandler(mockSettings)
+
+ @Test
+ fun `GIVEN empty blocklist WHEN action intercepted THEN unchanged by middleware`() {
+ val updatedBookmark = RecentBookmark(url = "https://www.mozilla.org/")
+
+ every { mockSettings.homescreenBlocklist } returns setOf()
+ val middleware = BlocklistMiddleware(blocklistHandler)
+ val appStore = AppStore(
+ AppState(),
+ middlewares = listOf(middleware),
+ )
+
+ appStore.dispatch(
+ AppAction.Change(
+ topSites = appStore.state.topSites,
+ mode = appStore.state.mode,
+ collections = appStore.state.collections,
+ showCollectionPlaceholder = appStore.state.showCollectionPlaceholder,
+ recentTabs = appStore.state.recentTabs,
+ recentBookmarks = listOf(updatedBookmark),
+ recentHistory = appStore.state.recentHistory,
+ recentSyncedTabState = appStore.state.recentSyncedTabState,
+ ),
+ ).joinBlocking()
+
+ assertEquals(updatedBookmark, appStore.state.recentBookmarks[0])
+ }
+
+ @Test
+ fun `GIVEN non-empty blocklist WHEN action intercepted with no matching elements THEN unchanged by middleware`() {
+ val updatedBookmark = RecentBookmark(url = "https://www.mozilla.org/")
+
+ every { mockSettings.homescreenBlocklist } returns setOf("https://www.github.org/".stripAndHash())
+ val middleware = BlocklistMiddleware(blocklistHandler)
+ val appStore = AppStore(
+ AppState(),
+ middlewares = listOf(middleware),
+ )
+
+ appStore.dispatch(
+ AppAction.Change(
+ topSites = appStore.state.topSites,
+ mode = appStore.state.mode,
+ collections = appStore.state.collections,
+ showCollectionPlaceholder = appStore.state.showCollectionPlaceholder,
+ recentTabs = appStore.state.recentTabs,
+ recentBookmarks = listOf(updatedBookmark),
+ recentHistory = appStore.state.recentHistory,
+ recentSyncedTabState = appStore.state.recentSyncedTabState,
+ ),
+ ).joinBlocking()
+
+ assertEquals(updatedBookmark, appStore.state.recentBookmarks[0])
+ }
+
+ @Test
+ fun `GIVEN non-empty blocklist with specific pages WHEN action intercepted with matching host THEN unchanged by middleware`() {
+ val updatedBookmark = RecentBookmark(url = "https://github.com/")
+
+ every { mockSettings.homescreenBlocklist } returns setOf("https://github.com/mozilla-mobile/fenix".stripAndHash())
+ val middleware = BlocklistMiddleware(blocklistHandler)
+ val appStore = AppStore(
+ AppState(),
+ middlewares = listOf(middleware),
+ )
+
+ appStore.dispatch(
+ AppAction.Change(
+ topSites = appStore.state.topSites,
+ mode = appStore.state.mode,
+ collections = appStore.state.collections,
+ showCollectionPlaceholder = appStore.state.showCollectionPlaceholder,
+ recentTabs = appStore.state.recentTabs,
+ recentBookmarks = listOf(updatedBookmark),
+ recentHistory = appStore.state.recentHistory,
+ recentSyncedTabState = appStore.state.recentSyncedTabState,
+ ),
+ ).joinBlocking()
+
+ assertEquals(updatedBookmark, appStore.state.recentBookmarks[0])
+ }
+
+ @Test
+ fun `GIVEN non-empty blocklist WHEN action intercepted with matching elements THEN filtered by middleware`() {
+ val updatedBookmark = RecentBookmark(url = "https://www.mozilla.org/")
+
+ every { mockSettings.homescreenBlocklist } returns setOf("https://www.mozilla.org/".stripAndHash())
+ val middleware = BlocklistMiddleware(blocklistHandler)
+ val appStore = AppStore(
+ AppState(),
+ middlewares = listOf(middleware),
+ )
+
+ appStore.dispatch(
+ AppAction.Change(
+ topSites = appStore.state.topSites,
+ mode = appStore.state.mode,
+ collections = appStore.state.collections,
+ showCollectionPlaceholder = appStore.state.showCollectionPlaceholder,
+ recentTabs = appStore.state.recentTabs,
+ recentBookmarks = listOf(updatedBookmark),
+ recentHistory = appStore.state.recentHistory,
+ recentSyncedTabState = appStore.state.recentSyncedTabState,
+ ),
+ ).joinBlocking()
+
+ assertTrue(appStore.state.recentBookmarks.isEmpty())
+ }
+
+ @Test
+ fun `GIVEN non-empty blocklist WHEN action intercepted with matching elements THEN all relevant sections filtered by middleware`() {
+ val blockedUrl = "https://www.mozilla.org/"
+ val updatedBookmarks = listOf(RecentBookmark(url = blockedUrl))
+ val updatedRecentTabs = listOf(RecentTab.Tab(createTab(url = blockedUrl)))
+
+ every { mockSettings.homescreenBlocklist } returns setOf(blockedUrl.stripAndHash())
+ val middleware = BlocklistMiddleware(blocklistHandler)
+ val appStore = AppStore(
+ AppState(),
+ middlewares = listOf(middleware),
+ )
+
+ appStore.dispatch(
+ AppAction.Change(
+ topSites = appStore.state.topSites,
+ mode = appStore.state.mode,
+ collections = appStore.state.collections,
+ showCollectionPlaceholder = appStore.state.showCollectionPlaceholder,
+ recentTabs = updatedRecentTabs,
+ recentBookmarks = updatedBookmarks,
+ recentHistory = appStore.state.recentHistory,
+ recentSyncedTabState = appStore.state.recentSyncedTabState,
+ ),
+ ).joinBlocking()
+
+ assertTrue(appStore.state.recentBookmarks.isEmpty())
+ assertTrue(appStore.state.recentTabs.isEmpty())
+ }
+
+ @Test
+ fun `GIVEN non-empty blocklist WHEN action intercepted with matching elements THEN only matching urls removed`() {
+ val blockedUrl = "https://www.mozilla.org/"
+ val unblockedUrl = "https://www.github.org/"
+ val unblockedBookmark = RecentBookmark(unblockedUrl)
+ val updatedBookmarks = listOf(
+ RecentBookmark(url = blockedUrl),
+ unblockedBookmark,
+ )
+ val unblockedRecentTab = RecentTab.Tab(createTab(url = unblockedUrl))
+ val updatedRecentTabs =
+ listOf(RecentTab.Tab(createTab(url = blockedUrl)), unblockedRecentTab)
+
+ every { mockSettings.homescreenBlocklist } returns setOf(blockedUrl.stripAndHash())
+ every { mockSettings.frecencyFilterQuery } returns ""
+ val middleware = BlocklistMiddleware(blocklistHandler)
+ val appStore = AppStore(
+ AppState(),
+ middlewares = listOf(middleware),
+ )
+
+ appStore.dispatch(
+ AppAction.Change(
+ topSites = appStore.state.topSites,
+ mode = appStore.state.mode,
+ collections = appStore.state.collections,
+ showCollectionPlaceholder = appStore.state.showCollectionPlaceholder,
+ recentTabs = updatedRecentTabs,
+ recentBookmarks = updatedBookmarks,
+ recentHistory = appStore.state.recentHistory,
+ recentSyncedTabState = appStore.state.recentSyncedTabState,
+ ),
+ ).joinBlocking()
+
+ assertEquals(unblockedBookmark, appStore.state.recentBookmarks[0])
+ assertEquals(unblockedRecentTab, appStore.state.recentTabs[0])
+ }
+
+ @Test
+ fun `WHEN remove action intercepted THEN hashed url added to blocklist and Change action dispatched`() {
+ val captureMiddleware = CaptureActionsMiddleware<AppState, AppAction>()
+ val removedUrl = "https://www.mozilla.org/"
+ val removedBookmark = RecentBookmark(url = removedUrl)
+
+ val updateSlot = slot<Set<String>>()
+ every { mockSettings.homescreenBlocklist } returns setOf() andThen setOf(removedUrl.stripAndHash())
+ every { mockSettings.homescreenBlocklist = capture(updateSlot) } returns Unit
+ val middleware = BlocklistMiddleware(blocklistHandler)
+ val appStore = AppStore(
+ AppState(recentBookmarks = listOf(removedBookmark)),
+ middlewares = listOf(middleware, captureMiddleware),
+ )
+
+ appStore.dispatch(
+ AppAction.RemoveRecentBookmark(removedBookmark),
+ ).joinBlocking()
+
+ val capturedAction = captureMiddleware.findFirstAction(AppAction.Change::class)
+ assertEquals(emptyList<RecentBookmark>(), capturedAction.recentBookmarks)
+ assertEquals(setOf(removedUrl.stripAndHash()), updateSlot.captured)
+ }
+
+ @Test
+ fun `WHEN urls are compared to blocklist THEN protocols are stripped`() {
+ val host = "www.mozilla.org/"
+ val updatedBookmark = RecentBookmark(url = "http://$host")
+
+ every { mockSettings.homescreenBlocklist } returns setOf("https://$host".stripAndHash())
+ val middleware = BlocklistMiddleware(blocklistHandler)
+ val appStore = AppStore(
+ AppState(),
+ middlewares = listOf(middleware),
+ )
+
+ appStore.dispatch(
+ AppAction.Change(
+ topSites = appStore.state.topSites,
+ mode = appStore.state.mode,
+ collections = appStore.state.collections,
+ showCollectionPlaceholder = appStore.state.showCollectionPlaceholder,
+ recentTabs = appStore.state.recentTabs,
+ recentBookmarks = listOf(updatedBookmark),
+ recentHistory = appStore.state.recentHistory,
+ recentSyncedTabState = appStore.state.recentSyncedTabState,
+ ),
+ ).joinBlocking()
+
+ assertTrue(appStore.state.recentBookmarks.isEmpty())
+ }
+
+ @Test
+ fun `WHEN urls are compared to blocklist THEN common subdomains are stripped`() {
+ val host = "mozilla.org/"
+ val updatedBookmark = RecentBookmark(url = host)
+
+ every { mockSettings.homescreenBlocklist } returns setOf(host.stripAndHash())
+ val middleware = BlocklistMiddleware(blocklistHandler)
+ val appStore = AppStore(
+ AppState(),
+ middlewares = listOf(middleware),
+ )
+
+ appStore.dispatch(
+ AppAction.Change(
+ topSites = appStore.state.topSites,
+ mode = appStore.state.mode,
+ collections = appStore.state.collections,
+ showCollectionPlaceholder = appStore.state.showCollectionPlaceholder,
+ recentTabs = appStore.state.recentTabs,
+ recentBookmarks = listOf(updatedBookmark),
+ recentHistory = appStore.state.recentHistory,
+ recentSyncedTabState = appStore.state.recentSyncedTabState,
+ ),
+ ).joinBlocking()
+
+ assertTrue(appStore.state.recentBookmarks.isEmpty())
+ }
+
+ @Test
+ fun `WHEN urls are compared to blocklist THEN trailing slashes are stripped`() {
+ val host = "www.mozilla.org"
+ val updatedBookmark = RecentBookmark(url = "http://$host/")
+
+ every { mockSettings.homescreenBlocklist } returns setOf("https://$host".stripAndHash())
+ val middleware = BlocklistMiddleware(blocklistHandler)
+ val appStore = AppStore(
+ AppState(),
+ middlewares = listOf(middleware),
+ )
+
+ appStore.dispatch(
+ AppAction.Change(
+ topSites = appStore.state.topSites,
+ mode = appStore.state.mode,
+ collections = appStore.state.collections,
+ showCollectionPlaceholder = appStore.state.showCollectionPlaceholder,
+ recentTabs = appStore.state.recentTabs,
+ recentBookmarks = listOf(updatedBookmark),
+ recentHistory = appStore.state.recentHistory,
+ recentSyncedTabState = appStore.state.recentSyncedTabState,
+ ),
+ ).joinBlocking()
+
+ assertTrue(appStore.state.recentBookmarks.isEmpty())
+ }
+
+ @Test
+ fun `WHEN new recently synced tabs are submitted THEN urls matching the blocklist should be removed`() {
+ val blockedHost = "https://www.mozilla.org"
+ val blockedTab = RecentSyncedTab(
+ deviceDisplayName = "",
+ deviceType = mock(),
+ title = "",
+ url = "https://www.mozilla.org",
+ previewImageUrl = null,
+ )
+ val allowedTab = RecentSyncedTab(
+ deviceDisplayName = "",
+ deviceType = mock(),
+ title = "",
+ url = "https://github.com",
+ previewImageUrl = null,
+ )
+
+ every { mockSettings.homescreenBlocklist } returns setOf(blockedHost.stripAndHash())
+ every { mockSettings.frecencyFilterQuery } returns ""
+ val middleware = BlocklistMiddleware(blocklistHandler)
+ val appStore = AppStore(
+ AppState(),
+ middlewares = listOf(middleware),
+ )
+
+ appStore.dispatch(
+ AppAction.RecentSyncedTabStateChange(
+ RecentSyncedTabState.Success(
+ listOf(
+ blockedTab,
+ allowedTab,
+ ),
+ ),
+ ),
+ ).joinBlocking()
+
+ assertEquals(
+ allowedTab,
+ (appStore.state.recentSyncedTabState as RecentSyncedTabState.Success).tabs.single(),
+ )
+ }
+
+ @Test
+ fun `WHEN the recent synced tab state is changed to None or Loading THEN the middleware does not change the state`() {
+ val blockedHost = "https://www.mozilla.org"
+ every { mockSettings.homescreenBlocklist } returns setOf(blockedHost.stripAndHash())
+ val middleware = BlocklistMiddleware(blocklistHandler)
+ val appStore = AppStore(
+ AppState(),
+ middlewares = listOf(middleware),
+ )
+
+ appStore.dispatch(
+ AppAction.RecentSyncedTabStateChange(
+ RecentSyncedTabState.None,
+ ),
+ ).joinBlocking()
+
+ assertEquals(RecentSyncedTabState.None, appStore.state.recentSyncedTabState)
+ }
+
+ @Test
+ fun `WHEN all recently synced submitted tabs are blocked THEN the recent synced tab state should be set to None`() {
+ val blockedHost = "https://www.mozilla.org"
+ val blockedTab = RecentSyncedTab(
+ deviceDisplayName = "",
+ deviceType = mock(),
+ title = "",
+ url = "https://www.mozilla.org",
+ previewImageUrl = null,
+ )
+
+ every { mockSettings.homescreenBlocklist } returns setOf(blockedHost.stripAndHash())
+ val middleware = BlocklistMiddleware(blocklistHandler)
+ val appStore = AppStore(
+ AppState(),
+ middlewares = listOf(middleware),
+ )
+
+ appStore.dispatch(
+ AppAction.RecentSyncedTabStateChange(
+ RecentSyncedTabState.Success(
+ listOf(blockedTab),
+ ),
+ ),
+ ).joinBlocking()
+
+ assertEquals(
+ RecentSyncedTabState.None,
+ appStore.state.recentSyncedTabState,
+ )
+ }
+
+ @Test
+ fun `WHEN the most recent used synced tab is blocked THEN the following recent synced tabs remain ordered`() {
+ val tabUrls = listOf("link1", "link2", "link3")
+ val currentTabs = listOf(
+ RecentSyncedTab(
+ deviceDisplayName = "device1",
+ deviceType = mock(),
+ title = "",
+ url = tabUrls[0],
+ previewImageUrl = null,
+ ),
+ RecentSyncedTab(
+ deviceDisplayName = "",
+ deviceType = mock(),
+ title = "",
+ url = tabUrls[1],
+ previewImageUrl = null,
+ ),
+ RecentSyncedTab(
+ deviceDisplayName = "",
+ deviceType = mock(),
+ title = "",
+ url = tabUrls[2],
+ previewImageUrl = null,
+ ),
+ )
+ val appStore = AppStore(
+ AppState(recentSyncedTabState = RecentSyncedTabState.Success(currentTabs)),
+ middlewares = listOf(BlocklistMiddleware(blocklistHandler)),
+ )
+ val updateSlot = slot<Set<String>>()
+ every { mockSettings.homescreenBlocklist = capture(updateSlot) } returns Unit
+ every { mockSettings.homescreenBlocklist } returns setOf(tabUrls[0].stripAndHash())
+ every { mockSettings.frecencyFilterQuery } returns ""
+
+ appStore.dispatch(
+ AppAction.RemoveRecentSyncedTab(
+ currentTabs.first(),
+ ),
+ ).joinBlocking()
+
+ assertEquals(
+ 2,
+ (appStore.state.recentSyncedTabState as RecentSyncedTabState.Success).tabs.size,
+ )
+ assertEquals(setOf(tabUrls[0].stripAndHash()), updateSlot.captured)
+ assertEquals(
+ currentTabs[1],
+ (appStore.state.recentSyncedTabState as RecentSyncedTabState.Success).tabs.firstOrNull(),
+ )
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/AssistIntentProcessorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/AssistIntentProcessorTest.kt
new file mode 100644
index 0000000000..bc122b96b2
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/AssistIntentProcessorTest.kt
@@ -0,0 +1,67 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.intent
+
+import android.content.Intent
+import androidx.navigation.NavController
+import androidx.navigation.navOptions
+import io.mockk.Called
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Assert.assertFalse
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.NavGraphDirections
+import org.mozilla.fenix.R
+import org.mozilla.fenix.components.metrics.MetricsUtils
+import org.mozilla.fenix.ext.nav
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class AssistIntentProcessorTest {
+ private val navController: NavController = mockk(relaxed = true)
+ private val out: Intent = mockk(relaxed = true)
+
+ @Test
+ fun `GIVEN an intent with wrong action WHEN it is processed THEN nothing should happen`() {
+ val intent = Intent().apply {
+ action = TEST_WRONG_ACTION
+ }
+ val result = StartSearchIntentProcessor().process(intent, navController, out)
+
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ assertFalse(result)
+ }
+
+ @Test
+ fun `GIVEN an intent with ACTION_ASSIST action WHEN it is processed THEN navigate to the search dialog`() {
+ val intent = Intent().apply {
+ action = Intent.ACTION_ASSIST
+ }
+
+ AssistIntentProcessor().process(intent, navController, out)
+ val options = navOptions {
+ popUpTo(R.id.homeFragment)
+ }
+
+ verify {
+ navController.nav(
+ null,
+ NavGraphDirections.actionGlobalSearchDialog(
+ sessionId = null,
+ searchAccessPoint = MetricsUtils.Source.NONE,
+ ),
+ options,
+ )
+ }
+
+ verify { out wasNot Called }
+ }
+
+ companion object {
+ const val TEST_WRONG_ACTION = "test-action"
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/CrashReporterIntentProcessorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/CrashReporterIntentProcessorTest.kt
new file mode 100644
index 0000000000..4870c07527
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/CrashReporterIntentProcessorTest.kt
@@ -0,0 +1,57 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.intent
+
+import android.content.Intent
+import androidx.navigation.NavController
+import io.mockk.Called
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.verify
+import mozilla.components.lib.crash.Crash
+import mozilla.components.lib.crash.Crash.NativeCodeCrash
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppAction
+
+class CrashReporterIntentProcessorTest {
+ private val appStore: AppStore = mockk(relaxed = true)
+ private val navController: NavController = mockk()
+ private val out: Intent = mockk()
+
+ @Test
+ fun `GIVEN a blank Intent WHEN processing it THEN do nothing and return false`() {
+ val processor = CrashReporterIntentProcessor(appStore)
+
+ val result = processor.process(Intent(), navController, out)
+
+ assertFalse(result)
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ verify { appStore wasNot Called }
+ }
+
+ @Test
+ fun `GIVEN a crash Intent WHEN processing it THEN update crash details and return true`() {
+ val processor = CrashReporterIntentProcessor(appStore)
+ val intent = Intent()
+ val crash = mockk<NativeCodeCrash>(relaxed = true)
+
+ mockkObject(Crash.Companion) {
+ every { Crash.Companion.isCrashIntent(intent) } returns true
+ every { Crash.Companion.fromIntent(intent) } returns crash
+
+ val result = processor.process(intent, navController, out)
+
+ assertTrue(result)
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ verify { appStore.dispatch(AppAction.AddNonFatalCrash(crash)) }
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/FennecBookmarkShortcutsIntentProcessorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/FennecBookmarkShortcutsIntentProcessorTest.kt
new file mode 100644
index 0000000000..c158250f92
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/FennecBookmarkShortcutsIntentProcessorTest.kt
@@ -0,0 +1,76 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.intent
+
+import android.content.Intent
+import android.net.Uri
+import io.mockk.Called
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.state.state.SessionState
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.feature.intent.ext.getSessionId
+import mozilla.components.feature.tabs.TabsUseCases
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.home.intent.FennecBookmarkShortcutsIntentProcessor.Companion.ACTION_FENNEC_HOMESCREEN_SHORTCUT
+
+@RunWith(FenixRobolectricTestRunner::class)
+class FennecBookmarkShortcutsIntentProcessorTest {
+ private val addNewTabUseCase = mockk<TabsUseCases.AddNewTabUseCase>(relaxed = true)
+
+ @Test
+ fun `do not process blank Intents`() = runTest {
+ val processor = FennecBookmarkShortcutsIntentProcessor(addNewTabUseCase)
+ val fennecShortcutsIntent = Intent(ACTION_FENNEC_HOMESCREEN_SHORTCUT)
+ fennecShortcutsIntent.data = Uri.parse("http://mozilla.org")
+
+ val wasEmptyIntentProcessed = processor.process(Intent())
+
+ assertFalse(wasEmptyIntentProcessed)
+ verify {
+ addNewTabUseCase wasNot Called
+ }
+ }
+
+ @Test
+ fun `processing a Fennec shortcut Intent results in loading it's URL in a new Session`() = runTest {
+ val expectedSessionId = "test"
+ val processor = FennecBookmarkShortcutsIntentProcessor(addNewTabUseCase)
+ val fennecShortcutsIntent = Intent(ACTION_FENNEC_HOMESCREEN_SHORTCUT)
+ val testUrl = "http://mozilla.org"
+ fennecShortcutsIntent.data = Uri.parse(testUrl)
+
+ every {
+ addNewTabUseCase(
+ url = testUrl,
+ flags = EngineSession.LoadUrlFlags.external(),
+ source = SessionState.Source.Internal.HomeScreen,
+ selectTab = true,
+ startLoading = true,
+ )
+ } returns expectedSessionId
+
+ val wasIntentProcessed = processor.process(fennecShortcutsIntent)
+ assertTrue(wasIntentProcessed)
+ assertEquals(Intent.ACTION_VIEW, fennecShortcutsIntent.action)
+ assertEquals(expectedSessionId, fennecShortcutsIntent.getSessionId())
+ verify {
+ addNewTabUseCase(
+ url = testUrl,
+ flags = EngineSession.LoadUrlFlags.external(),
+ source = SessionState.Source.Internal.HomeScreen,
+ selectTab = true,
+ startLoading = true,
+ )
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/HomeDeepLinkIntentProcessorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/HomeDeepLinkIntentProcessorTest.kt
new file mode 100644
index 0000000000..1827f5eb16
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/HomeDeepLinkIntentProcessorTest.kt
@@ -0,0 +1,310 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.intent
+
+import android.app.Activity
+import android.content.Intent
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import android.os.Build.VERSION_CODES.M
+import androidx.core.net.toUri
+import androidx.navigation.NavController
+import io.mockk.Called
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.appservices.places.BookmarkRoot
+import mozilla.components.concept.engine.EngineSession
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.BrowserDirection
+import org.mozilla.fenix.BuildConfig.DEEP_LINK_SCHEME
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.NavGraphDirections
+import org.mozilla.fenix.browser.browsingmode.BrowsingMode
+import org.mozilla.fenix.components.accounts.FenixFxAEntryPoint
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.SupportUtils
+import org.robolectric.annotation.Config
+
+@RunWith(FenixRobolectricTestRunner::class)
+class HomeDeepLinkIntentProcessorTest {
+ private lateinit var activity: HomeActivity
+ private lateinit var navController: NavController
+ private lateinit var out: Intent
+ private lateinit var processorHome: HomeDeepLinkIntentProcessor
+
+ @Before
+ fun setup() {
+ activity = mockk(relaxed = true)
+ navController = mockk(relaxed = true)
+ out = mockk()
+ processorHome = HomeDeepLinkIntentProcessor(activity, ::showAddSearchWidgetPrompt)
+ }
+
+ @Test
+ fun `do not process blank intents`() {
+ assertFalse(processorHome.process(Intent(), navController, out))
+
+ verify { activity wasNot Called }
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `return true if scheme is fenix`() {
+ assertTrue(processorHome.process(testIntent("test"), navController, out))
+
+ verify { activity wasNot Called }
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `return true if scheme is a fenix variant`() {
+ assertTrue(processorHome.process(testIntent("fenix-beta://test"), navController, out))
+
+ verify { activity wasNot Called }
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `process home deep link`() {
+ assertTrue(processorHome.process(testIntent("home"), navController, out))
+
+ verify { activity wasNot Called }
+ verify { navController.navigate(NavGraphDirections.actionGlobalHome()) }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `process urls_bookmarks deep link`() {
+ assertTrue(processorHome.process(testIntent("urls_bookmarks"), navController, out))
+
+ verify { navController.navigate(NavGraphDirections.actionGlobalBookmarkFragment(BookmarkRoot.Root.id)) }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `process urls_history deep link`() {
+ assertTrue(processorHome.process(testIntent("urls_history"), navController, out))
+
+ verify { navController.navigate(NavGraphDirections.actionGlobalHistoryFragment()) }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `process home_collections deep link`() {
+ assertTrue(processorHome.process(testIntent("home_collections"), navController, out))
+
+ verify { navController.navigate(NavGraphDirections.actionGlobalHome()) }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `process settings deep link`() {
+ assertTrue(processorHome.process(testIntent("settings"), navController, out))
+
+ verify { activity wasNot Called }
+ verify { navController.navigate(NavGraphDirections.actionGlobalSettingsFragment()) }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `process turn_on_sync deep link`() {
+ assertTrue(processorHome.process(testIntent("turn_on_sync"), navController, out))
+
+ verify { activity wasNot Called }
+ verify {
+ navController.navigate(
+ NavGraphDirections.actionGlobalTurnOnSync(
+ entrypoint = FenixFxAEntryPoint.DeepLink,
+ ),
+ )
+ }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `process settings_search_engine deep link`() {
+ assertTrue(processorHome.process(testIntent("settings_search_engine"), navController, out))
+
+ verify { activity wasNot Called }
+ verify { navController.navigate(NavGraphDirections.actionGlobalSearchEngineFragment()) }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `process settings_accessibility deep link`() {
+ assertTrue(processorHome.process(testIntent("settings_accessibility"), navController, out))
+
+ verify { activity wasNot Called }
+ verify { navController.navigate(NavGraphDirections.actionGlobalAccessibilityFragment()) }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `process settings_delete_browsing_data deep link`() {
+ assertTrue(processorHome.process(testIntent("settings_delete_browsing_data"), navController, out))
+
+ verify { activity wasNot Called }
+ verify { navController.navigate(NavGraphDirections.actionGlobalDeleteBrowsingDataFragment()) }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `process settings_addon_manager deep link`() {
+ assertTrue(processorHome.process(testIntent("settings_addon_manager"), navController, out))
+
+ verify { navController.navigate(NavGraphDirections.actionGlobalAddonsManagementFragment()) }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `process settings_logins deep link`() {
+ assertTrue(processorHome.process(testIntent("settings_logins"), navController, out))
+
+ verify { navController.navigate(NavGraphDirections.actionGlobalSavedLoginsAuthFragment()) }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `process settings_tracking_protection deep link`() {
+ assertTrue(processorHome.process(testIntent("settings_tracking_protection"), navController, out))
+
+ verify { navController.navigate(NavGraphDirections.actionGlobalTrackingProtectionFragment()) }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `process settings_privacy deep link`() {
+ assertTrue(processorHome.process(testIntent("settings_privacy"), navController, out))
+
+ verify { navController.navigate(NavGraphDirections.actionGlobalSettingsFragment()) }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `process enable_private_browsing deep link`() {
+ assertTrue(processorHome.process(testIntent("enable_private_browsing"), navController, out))
+
+ verify { activity.browsingModeManager.mode = BrowsingMode.Private }
+ verify { navController.navigate(NavGraphDirections.actionGlobalHome()) }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `process open deep link`() {
+ assertTrue(processorHome.process(testIntent("open"), navController, out))
+
+ verify { activity wasNot Called }
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+
+ assertTrue(processorHome.process(testIntent("open?url=test"), navController, out))
+
+ verify { activity wasNot Called }
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+
+ assertTrue(processorHome.process(testIntent("open?url=https%3A%2F%2Fwww.example.org%2F"), navController, out))
+
+ verify {
+ activity.openToBrowserAndLoad(
+ "https://www.example.org/",
+ newTab = true,
+ from = BrowserDirection.FromGlobal,
+ flags = EngineSession.LoadUrlFlags.external(),
+ )
+ }
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `process invalid open deep link`() {
+ val invalidProcessor = HomeDeepLinkIntentProcessor(activity)
+
+ assertTrue(invalidProcessor.process(testIntent("open"), navController, out))
+
+ verify { activity wasNot Called }
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+
+ assertTrue(invalidProcessor.process(testIntent("open?url=open?url=https%3A%2F%2Fwww.example.org%2F"), navController, out))
+
+ verify { activity wasNot Called }
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ @Config(maxSdk = M)
+ fun `process make_default_browser deep link for API 23 and below`() {
+ val packageManager: PackageManager = mockk()
+ val packageInfo = PackageInfo()
+
+ every { activity.packageName } returns "org.mozilla.fenix"
+ every { activity.packageManager } returns packageManager
+ @Suppress("DEPRECATION")
+ every { packageManager.getPackageInfo("org.mozilla.fenix", 0) } returns packageInfo
+ packageInfo.versionName = "versionName"
+
+ assertTrue(processorHome.process(testIntent("make_default_browser"), navController, out))
+
+ val searchTermOrURL =
+ SupportUtils.getGenericSumoURLForTopic(
+ topic = SupportUtils.SumoTopic.SET_AS_DEFAULT_BROWSER,
+ )
+
+ verify {
+ activity.openToBrowserAndLoad(
+ searchTermOrURL = searchTermOrURL,
+ newTab = true,
+ from = BrowserDirection.FromGlobal,
+ flags = EngineSession.LoadUrlFlags.external(),
+ )
+ }
+
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `process settings_notifications deep link`() {
+ assertTrue(processorHome.process(testIntent("settings_notifications"), navController, out))
+
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ verify { activity.startActivity(any()) }
+ }
+
+ @Test
+ fun `process settings_wallpapers deep link`() {
+ assertTrue(processorHome.process(testIntent("settings_wallpapers"), navController, out))
+
+ verify { navController.navigate(NavGraphDirections.actionGlobalWallpaperSettingsFragment()) }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `process install_search_widget deep link`() {
+ assertTrue(processorHome.process(testIntent("install_search_widget"), navController, out))
+
+ verify { showAddSearchWidgetPrompt(activity) }
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ }
+
+ private fun testIntent(uri: String) = Intent("", "$DEEP_LINK_SCHEME://$uri".toUri())
+
+ private fun showAddSearchWidgetPrompt(activity: Activity) {
+ println("$activity add search widget prompt was shown ")
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/OpenBrowserIntentProcessorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/OpenBrowserIntentProcessorTest.kt
new file mode 100644
index 0000000000..c96c7ef1d7
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/OpenBrowserIntentProcessorTest.kt
@@ -0,0 +1,60 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.intent
+
+import android.content.Intent
+import androidx.navigation.NavController
+import io.mockk.Called
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.BrowserDirection
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class OpenBrowserIntentProcessorTest {
+
+ private val activity: HomeActivity = mockk(relaxed = true)
+ private val navController: NavController = mockk()
+ private val out: Intent = mockk(relaxed = true)
+
+ @Test
+ fun `do not process blank intents`() {
+ val processor = OpenBrowserIntentProcessor(activity) { null }
+ processor.process(Intent(), navController, out)
+
+ verify { activity wasNot Called }
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `do not process when open extra is false`() {
+ val intent = Intent().apply {
+ putExtra(HomeActivity.OPEN_TO_BROWSER, false)
+ }
+ val processor = OpenBrowserIntentProcessor(activity) { null }
+ processor.process(intent, navController, out)
+
+ verify { activity wasNot Called }
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `process when open extra is true`() {
+ val intent = Intent().apply {
+ putExtra(HomeActivity.OPEN_TO_BROWSER, true)
+ }
+ val processor = OpenBrowserIntentProcessor(activity) { "session-id" }
+ processor.process(intent, navController, out)
+
+ verify { activity.openToBrowser(BrowserDirection.FromGlobal, "session-id") }
+ verify { navController wasNot Called }
+ verify { out.putExtra(HomeActivity.OPEN_TO_BROWSER, false) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/OpenPasswordManagerIntentProcessorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/OpenPasswordManagerIntentProcessorTest.kt
new file mode 100644
index 0000000000..f4779ef744
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/OpenPasswordManagerIntentProcessorTest.kt
@@ -0,0 +1,77 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.intent
+
+import android.content.Intent
+import androidx.navigation.NavController
+import io.mockk.Called
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.NavGraphDirections
+import org.mozilla.fenix.ext.nav
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.shortcut.PasswordManagerIntentProcessor
+
+@RunWith(FenixRobolectricTestRunner::class)
+class OpenPasswordManagerIntentProcessorTest {
+
+ private lateinit var activity: HomeActivity
+ private lateinit var navController: NavController
+ private lateinit var out: Intent
+ private lateinit var processor: OpenPasswordManagerIntentProcessor
+
+ @Before
+ fun setup() {
+ activity = mockk(relaxed = true)
+ navController = mockk(relaxed = true)
+ out = mockk(relaxed = true)
+ processor = OpenPasswordManagerIntentProcessor()
+ }
+
+ @Test
+ fun `GIVEN a blank intent WHEN it is processed THEN nothing should happen`() {
+ assertFalse(processor.process(Intent(), navController, out))
+
+ verify { activity wasNot Called }
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `GIVEN an intent with wrong action WHEN it is processed THEN nothing should happen`() {
+ val intent = Intent().apply {
+ action = TEST_WRONG_ACTION
+ }
+
+ assertFalse(processor.process(intent, navController, out))
+
+ verify { activity wasNot Called }
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `GIVEN an intent with correct action and extra boolean WHEN it is processed THEN navigate should be called`() {
+ val intent = Intent().apply {
+ action = PasswordManagerIntentProcessor.Companion.ACTION_OPEN_PASSWORD_MANAGER
+ putExtra(HomeActivity.OPEN_PASSWORD_MANAGER, true)
+ }
+
+ assertTrue(processor.process(intent, navController, out))
+
+ verify { navController.nav(null, NavGraphDirections.actionGlobalSavedLoginsAuthFragment()) }
+ verify { out.removeExtra(HomeActivity.OPEN_PASSWORD_MANAGER) }
+ }
+
+ companion object {
+ const val TEST_WRONG_ACTION = "test-action"
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/OpenSpecificTabIntentProcessorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/OpenSpecificTabIntentProcessorTest.kt
new file mode 100644
index 0000000000..045f5d6804
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/OpenSpecificTabIntentProcessorTest.kt
@@ -0,0 +1,109 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.intent
+
+import android.content.Intent
+import androidx.navigation.NavController
+import io.mockk.Called
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import io.mockk.verifyOrder
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.feature.media.service.AbstractMediaSessionService
+import mozilla.components.feature.tabs.TabsUseCases
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.BrowserDirection
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class OpenSpecificTabIntentProcessorTest {
+ private lateinit var activity: HomeActivity
+ private lateinit var navController: NavController
+ private lateinit var out: Intent
+ private lateinit var processor: OpenSpecificTabIntentProcessor
+
+ @Before
+ fun setup() {
+ activity = mockk(relaxed = true)
+ navController = mockk(relaxed = true)
+ out = mockk()
+ processor = OpenSpecificTabIntentProcessor(activity)
+ }
+
+ @Test
+ fun `GIVEN a blank intent WHEN it is processed THEN nothing should happen`() {
+ assertFalse(processor.process(Intent(), navController, out))
+
+ verify { activity wasNot Called }
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `GIVEN an intent with wrong action WHEN it is processed THEN nothing should happen`() {
+ val intent = Intent().apply {
+ action = TEST_WRONG_ACTION
+ }
+
+ assertFalse(processor.process(intent, navController, out))
+
+ verify { activity wasNot Called }
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `GIVEN an intent with null extra string WHEN it is processed THEN openToBrowser should not be called`() {
+ val intent = Intent().apply {
+ action = AbstractMediaSessionService.Companion.ACTION_SWITCH_TAB
+ }
+
+ val store = BrowserStore(BrowserState(tabs = listOf(createTab(id = TEST_SESSION_ID, url = "https:mozilla.org"))))
+ val tabUseCases: TabsUseCases = mockk(relaxed = true)
+ every { activity.components.core.store } returns store
+ every { activity.components.useCases.tabsUseCases } returns tabUseCases
+
+ assertFalse(processor.process(intent, navController, out))
+
+ verify(exactly = 0) { activity.openToBrowser(BrowserDirection.FromGlobal) }
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `GIVEN an intent with correct action and extra string WHEN it is processed THEN session should be selected and openToBrowser should be called`() {
+ val intent = Intent().apply {
+ action = AbstractMediaSessionService.Companion.ACTION_SWITCH_TAB
+ putExtra(AbstractMediaSessionService.Companion.EXTRA_TAB_ID, TEST_SESSION_ID)
+ }
+ val store = BrowserStore(BrowserState(tabs = listOf(createTab(id = TEST_SESSION_ID, url = "https:mozilla.org"))))
+ val tabUseCases: TabsUseCases = mockk(relaxed = true)
+ every { activity.components.core.store } returns store
+ every { activity.components.useCases.tabsUseCases } returns tabUseCases
+
+ assertTrue(processor.process(intent, navController, out))
+
+ verifyOrder {
+ tabUseCases.selectTab(TEST_SESSION_ID)
+ activity.openToBrowser(BrowserDirection.FromGlobal)
+ }
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ }
+
+ companion object {
+ const val TEST_WRONG_ACTION = "test-action"
+ const val TEST_SESSION_ID = "test-session-id"
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/ReEngagementIntentProcessorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/ReEngagementIntentProcessorTest.kt
new file mode 100644
index 0000000000..353fcb6b6f
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/ReEngagementIntentProcessorTest.kt
@@ -0,0 +1,116 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.intent
+
+import android.content.Intent
+import androidx.navigation.NavController
+import io.mockk.Called
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.BrowserDirection
+import org.mozilla.fenix.GleanMetrics.Events
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.NavGraphDirections
+import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.onboarding.ReEngagementNotificationWorker
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class ReEngagementIntentProcessorTest {
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @Test
+ fun `do not process blank intents`() {
+ val navController: NavController = mockk()
+ val out: Intent = mockk()
+ val settings: Settings = mockk()
+ val result = ReEngagementIntentProcessor(mockk(), settings)
+ .process(Intent(), navController, out)
+
+ assertFalse(result)
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `WHEN re-engagement notification type is type A THEN load target URL`() {
+ val navController: NavController = mockk(relaxed = true)
+ val out: Intent = mockk()
+ val activity: HomeActivity = mockk(relaxed = true)
+ val browsingModeManager: BrowsingModeManager = mockk(relaxed = true)
+ val settings: Settings = mockk(relaxed = true)
+
+ val intent = Intent().apply {
+ putExtra("org.mozilla.fenix.re-engagement.intent", true)
+ }
+ every { activity.applicationContext } returns testContext
+ every { activity.browsingModeManager } returns browsingModeManager
+ every { settings.reEngagementNotificationType } returns ReEngagementNotificationWorker.NOTIFICATION_TYPE_A
+
+ assertNull(Events.reEngagementNotifTapped.testGetValue())
+
+ val result = ReEngagementIntentProcessor(activity, settings)
+ .process(intent, navController, out)
+
+ assert(result)
+
+ assertNotNull(Events.reEngagementNotifTapped.testGetValue())
+ verify {
+ activity.openToBrowserAndLoad(
+ searchTermOrURL = ReEngagementNotificationWorker.NOTIFICATION_TARGET_URL,
+ newTab = true,
+ from = BrowserDirection.FromGlobal,
+ customTabSessionId = null,
+ engine = null,
+ forceSearch = false,
+ flags = EngineSession.LoadUrlFlags.external(),
+ requestDesktopMode = false,
+ historyMetadata = null,
+ )
+ }
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `WHEN re-engagement notification type is 2 THEN open search dialog`() {
+ val navController: NavController = mockk(relaxed = true)
+ val out: Intent = mockk()
+ val activity: HomeActivity = mockk(relaxed = true)
+ val browsingModeManager: BrowsingModeManager = mockk(relaxed = true)
+ val settings: Settings = mockk(relaxed = true)
+
+ val intent = Intent().apply {
+ putExtra("org.mozilla.fenix.re-engagement.intent", true)
+ }
+ every { activity.applicationContext } returns testContext
+ every { activity.browsingModeManager } returns browsingModeManager
+ every { settings.reEngagementNotificationType } returns ReEngagementNotificationWorker.NOTIFICATION_TYPE_B
+
+ assertNull(Events.reEngagementNotifTapped.testGetValue())
+
+ val result = ReEngagementIntentProcessor(activity, settings)
+ .process(intent, navController, out)
+
+ assert(result)
+
+ assertNotNull(Events.reEngagementNotifTapped.testGetValue())
+ val directions = NavGraphDirections.actionGlobalSearchDialog(sessionId = null)
+ verify { navController.navigate(directions, navOptions = any()) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/SpeechProcessingIntentProcessorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/SpeechProcessingIntentProcessorTest.kt
new file mode 100644
index 0000000000..964930a314
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/SpeechProcessingIntentProcessorTest.kt
@@ -0,0 +1,105 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.intent
+
+import android.content.Intent
+import androidx.navigation.NavController
+import androidx.test.core.app.ApplicationProvider
+import io.mockk.Called
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.SearchState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.feature.search.ext.createSearchEngine
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.BrowserDirection
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.helpers.perf.TestStrictModeManager
+import org.mozilla.fenix.widget.VoiceSearchActivity.Companion.SPEECH_PROCESSING
+
+@RunWith(FenixRobolectricTestRunner::class)
+class SpeechProcessingIntentProcessorTest {
+
+ private val activity: HomeActivity = mockk(relaxed = true)
+ private val navController: NavController = mockk(relaxed = true)
+ private val out: Intent = mockk(relaxed = true)
+
+ private val searchEngine = createSearchEngine(
+ name = "Test",
+ url = "https://www.example.org/?q={searchTerms}",
+ icon = mockk(),
+ )
+
+ private lateinit var store: BrowserStore
+
+ @Before
+ fun setup() {
+ val searchEngine = searchEngine
+
+ store = BrowserStore(
+ BrowserState(
+ search = SearchState(
+ customSearchEngines = listOf(searchEngine),
+ userSelectedSearchEngineId = searchEngine.id,
+ complete = true,
+ ),
+ ),
+ )
+
+ every { activity.applicationContext } returns ApplicationProvider.getApplicationContext()
+ }
+
+ @Test
+ fun `do not process blank intents`() {
+ val processor = SpeechProcessingIntentProcessor(activity, store)
+ processor.process(Intent(), navController, out)
+
+ verify { activity wasNot Called }
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `do not process when open extra is false`() {
+ val intent = Intent().apply {
+ putExtra(HomeActivity.OPEN_TO_BROWSER_AND_LOAD, false)
+ }
+ val processor = SpeechProcessingIntentProcessor(activity, store)
+ processor.process(intent, navController, out)
+
+ verify { activity wasNot Called }
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `reads the speech processing extra`() {
+ every { testContext.components.strictMode } returns TestStrictModeManager()
+ val intent = Intent().apply {
+ putExtra(HomeActivity.OPEN_TO_BROWSER_AND_LOAD, true)
+ putExtra(SPEECH_PROCESSING, "hello world")
+ }
+
+ val processor = SpeechProcessingIntentProcessor(activity, store)
+ processor.process(intent, mockk(), mockk(relaxed = true))
+
+ verify {
+ activity.openToBrowserAndLoad(
+ searchTermOrURL = "hello world",
+ newTab = true,
+ from = BrowserDirection.FromGlobal,
+ forceSearch = true,
+ engine = searchEngine,
+ )
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/StartSearchIntentProcessorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/StartSearchIntentProcessorTest.kt
new file mode 100644
index 0000000000..c3ab274657
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/intent/StartSearchIntentProcessorTest.kt
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.intent
+
+import android.content.Intent
+import androidx.navigation.NavController
+import androidx.navigation.navOptions
+import io.mockk.Called
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.SearchWidget
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.NavGraphDirections
+import org.mozilla.fenix.R
+import org.mozilla.fenix.components.metrics.MetricsUtils
+import org.mozilla.fenix.ext.nav
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class StartSearchIntentProcessorTest {
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ private val navController: NavController = mockk(relaxed = true)
+ private val out: Intent = mockk(relaxed = true)
+
+ @Test
+ fun `do not process blank intents`() {
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `do not process when search extra is false`() {
+ val intent = Intent().apply {
+ removeExtra(HomeActivity.OPEN_TO_SEARCH)
+ }
+ StartSearchIntentProcessor().process(intent, navController, out)
+
+ verify { navController wasNot Called }
+ verify { out wasNot Called }
+ }
+
+ @Test
+ fun `process search intents`() {
+ val intent = Intent().apply {
+ putExtra(HomeActivity.OPEN_TO_SEARCH, StartSearchIntentProcessor.SEARCH_WIDGET)
+ }
+ StartSearchIntentProcessor().process(intent, navController, out)
+ val options = navOptions {
+ popUpTo(R.id.homeFragment)
+ }
+
+ assertNotNull(SearchWidget.newTabButton.testGetValue())
+ val recordedEvents = SearchWidget.newTabButton.testGetValue()!!
+ assertEquals(1, recordedEvents.size)
+ assertEquals(null, recordedEvents.single().extra)
+
+ verify {
+ navController.nav(
+ null,
+ NavGraphDirections.actionGlobalSearchDialog(
+ sessionId = null,
+ searchAccessPoint = MetricsUtils.Source.WIDGET,
+ ),
+ options,
+ )
+ }
+ verify { out.removeExtra(HomeActivity.OPEN_TO_SEARCH) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/pocket/DefaultPocketStoriesControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/pocket/DefaultPocketStoriesControllerTest.kt
new file mode 100644
index 0000000000..340a404001
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/pocket/DefaultPocketStoriesControllerTest.kt
@@ -0,0 +1,358 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.pocket
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.spyk
+import io.mockk.verify
+import io.mockk.verifyOrder
+import mozilla.components.service.pocket.PocketStory
+import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory
+import mozilla.components.service.pocket.PocketStory.PocketSponsoredStory
+import mozilla.components.service.pocket.ext.getCurrentFlightImpressions
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.telemetry.glean.testing.GleanTestRule
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.BrowserDirection
+import org.mozilla.fenix.GleanMetrics.Pings
+import org.mozilla.fenix.GleanMetrics.Pocket
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppAction
+import org.mozilla.fenix.components.appstate.AppState
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class) // For gleanTestRule
+class DefaultPocketStoriesControllerTest {
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @Test
+ fun `GIVEN a category is selected WHEN that same category is clicked THEN deselect it and record telemetry`() {
+ val category1 = PocketRecommendedStoriesCategory("cat1", emptyList())
+ val category2 = PocketRecommendedStoriesCategory("cat2", emptyList())
+ val selections = listOf(PocketRecommendedStoriesSelectedCategory(category2.name))
+ val store = spyk(
+ AppStore(
+ AppState(
+ pocketStoriesCategories = listOf(category1, category2),
+ pocketStoriesCategoriesSelections = selections,
+ ),
+ ),
+ )
+ val controller = DefaultPocketStoriesController(mockk(), store)
+ assertNull(Pocket.homeRecsCategoryClicked.testGetValue())
+
+ controller.handleCategoryClick(category2)
+ verify(exactly = 0) { store.dispatch(AppAction.SelectPocketStoriesCategory(category2.name)) }
+ verify { store.dispatch(AppAction.DeselectPocketStoriesCategory(category2.name)) }
+
+ assertNotNull(Pocket.homeRecsCategoryClicked.testGetValue())
+ val event = Pocket.homeRecsCategoryClicked.testGetValue()!!
+ assertEquals(1, event.size)
+ assertTrue(event.single().extra!!.containsKey("category_name"))
+ assertEquals(category2.name, event.single().extra!!["category_name"])
+ assertTrue(event.single().extra!!.containsKey("new_state"))
+ assertEquals("deselected", event.single().extra!!["new_state"])
+ assertTrue(event.single().extra!!.containsKey("selected_total"))
+ assertEquals("1", event.single().extra!!["selected_total"])
+ }
+
+ @Test
+ fun `GIVEN 8 categories are selected WHEN when a new one is clicked THEN the oldest selected is deselected before selecting the new one and record telemetry`() {
+ val category1 = PocketRecommendedStoriesSelectedCategory(name = "cat1", selectionTimestamp = 111)
+ val category2 = PocketRecommendedStoriesSelectedCategory(name = "cat2", selectionTimestamp = 222)
+ val category3 = PocketRecommendedStoriesSelectedCategory(name = "cat3", selectionTimestamp = 333)
+ val oldestSelectedCategory = PocketRecommendedStoriesSelectedCategory(name = "oldestSelectedCategory", selectionTimestamp = 0)
+ val category4 = PocketRecommendedStoriesSelectedCategory(name = "cat4", selectionTimestamp = 444)
+ val category5 = PocketRecommendedStoriesSelectedCategory(name = "cat5", selectionTimestamp = 555)
+ val category6 = PocketRecommendedStoriesSelectedCategory(name = "cat6", selectionTimestamp = 678)
+ val category7 = PocketRecommendedStoriesSelectedCategory(name = "cat7", selectionTimestamp = 890)
+ val newSelectedCategory = PocketRecommendedStoriesSelectedCategory(name = "newSelectedCategory", selectionTimestamp = 654321)
+ val store = spyk(
+ AppStore(
+ AppState(
+ pocketStoriesCategoriesSelections = listOf(
+ category1,
+ category2,
+ category3,
+ category4,
+ category5,
+ category6,
+ category7,
+ oldestSelectedCategory,
+ ),
+ ),
+ ),
+ )
+ val controller = DefaultPocketStoriesController(mockk(), store)
+ assertNull(Pocket.homeRecsCategoryClicked.testGetValue())
+
+ controller.handleCategoryClick(PocketRecommendedStoriesCategory(newSelectedCategory.name))
+
+ verify { store.dispatch(AppAction.DeselectPocketStoriesCategory(oldestSelectedCategory.name)) }
+ verify { store.dispatch(AppAction.SelectPocketStoriesCategory(newSelectedCategory.name)) }
+
+ assertNotNull(Pocket.homeRecsCategoryClicked.testGetValue())
+ val event = Pocket.homeRecsCategoryClicked.testGetValue()!!
+ assertEquals(1, event.size)
+ assertTrue(event.single().extra!!.containsKey("category_name"))
+ assertEquals(newSelectedCategory.name, event.single().extra!!["category_name"])
+ assertTrue(event.single().extra!!.containsKey("new_state"))
+ assertEquals("selected", event.single().extra!!["new_state"])
+ assertTrue(event.single().extra!!.containsKey("selected_total"))
+ assertEquals("8", event.single().extra!!["selected_total"])
+ }
+
+ @Test
+ fun `GIVEN fewer than 8 categories are selected WHEN when a new one is clicked THEN don't deselect anything but select the newly clicked category and record telemetry`() {
+ val category1 = PocketRecommendedStoriesSelectedCategory(name = "cat1", selectionTimestamp = 111)
+ val category2 = PocketRecommendedStoriesSelectedCategory(name = "cat2", selectionTimestamp = 222)
+ val category3 = PocketRecommendedStoriesSelectedCategory(name = "cat3", selectionTimestamp = 333)
+ val oldestSelectedCategory = PocketRecommendedStoriesSelectedCategory(name = "oldestSelectedCategory", selectionTimestamp = 0)
+ val category4 = PocketRecommendedStoriesSelectedCategory(name = "cat4", selectionTimestamp = 444)
+ val category5 = PocketRecommendedStoriesSelectedCategory(name = "cat5", selectionTimestamp = 555)
+ val category6 = PocketRecommendedStoriesSelectedCategory(name = "cat6", selectionTimestamp = 678)
+ val store = spyk(
+ AppStore(
+ AppState(
+ pocketStoriesCategoriesSelections = listOf(
+ category1,
+ category2,
+ category3,
+ category4,
+ category5,
+ category6,
+ oldestSelectedCategory,
+ ),
+ ),
+ ),
+ )
+ val newSelectedCategoryName = "newSelectedCategory"
+ val controller = DefaultPocketStoriesController(mockk(), store)
+
+ controller.handleCategoryClick(PocketRecommendedStoriesCategory(newSelectedCategoryName))
+
+ verify(exactly = 0) { store.dispatch(AppAction.DeselectPocketStoriesCategory(oldestSelectedCategory.name)) }
+ verify { store.dispatch(AppAction.SelectPocketStoriesCategory(newSelectedCategoryName)) }
+
+ assertNotNull(Pocket.homeRecsCategoryClicked.testGetValue())
+ val event = Pocket.homeRecsCategoryClicked.testGetValue()!!
+ assertEquals(1, event.size)
+ assertTrue(event.single().extra!!.containsKey("category_name"))
+ assertEquals(newSelectedCategoryName, event.single().extra!!["category_name"])
+ assertTrue(event.single().extra!!.containsKey("new_state"))
+ assertEquals("selected", event.single().extra!!["new_state"])
+ assertTrue(event.single().extra!!.containsKey("selected_total"))
+ assertEquals("7", event.single().extra!!["selected_total"])
+ }
+
+ @Test
+ fun `WHEN a new recommended story is shown THEN update the State`() {
+ val store = spyk(AppStore())
+ val controller = DefaultPocketStoriesController(mockk(), store)
+ val storyShown: PocketRecommendedStory = mockk()
+ val storyGridLocation = 1 to 2
+
+ controller.handleStoryShown(storyShown, storyGridLocation)
+
+ verify { store.dispatch(AppAction.PocketStoriesShown(listOf(storyShown))) }
+ }
+
+ @Test
+ fun `WHEN a new sponsored story is shown THEN update the State and record telemetry`() {
+ val store = spyk(AppStore())
+ val controller = DefaultPocketStoriesController(mockk(), store)
+ val storyShown: PocketSponsoredStory = mockk {
+ every { shim.click } returns "testClickShim"
+ every { shim.impression } returns "testImpressionShim"
+ every { id } returns 123
+ }
+ var wasPingSent = false
+ mockkStatic("mozilla.components.service.pocket.ext.PocketStoryKt") {
+ // Simulate that the story was already shown 3 times.
+ every { storyShown.getCurrentFlightImpressions() } returns listOf(2L, 3L, 7L)
+ // Test that the spoc ping is immediately sent with the needed data.
+ Pings.spoc.testBeforeNextSubmit { reason ->
+ assertEquals(storyShown.shim.impression, Pocket.spocShim.testGetValue())
+ assertEquals(Pings.spocReasonCodes.impression.name, reason?.name)
+ wasPingSent = true
+ }
+
+ controller.handleStoryShown(storyShown, 1 to 2)
+
+ verify { store.dispatch(AppAction.PocketStoriesShown(listOf(storyShown))) }
+ assertNotNull(Pocket.homeRecsSpocShown.testGetValue())
+ assertEquals(1, Pocket.homeRecsSpocShown.testGetValue()!!.size)
+ val data = Pocket.homeRecsSpocShown.testGetValue()!!.single().extra
+ assertEquals("123", data?.entries?.first { it.key == "spoc_id" }?.value)
+ assertEquals("1x2", data?.entries?.first { it.key == "position" }?.value)
+ assertEquals("4", data?.entries?.first { it.key == "times_shown" }?.value)
+ assertTrue(wasPingSent)
+ }
+ }
+
+ @Test
+ fun `WHEN new stories are shown THEN update the State and record telemetry`() {
+ val store = spyk(AppStore())
+ val controller = DefaultPocketStoriesController(mockk(), store)
+ val storiesShown: List<PocketStory> = mockk()
+ assertNull(Pocket.homeRecsShown.testGetValue())
+
+ controller.handleStoriesShown(storiesShown)
+
+ verify { store.dispatch(AppAction.PocketStoriesShown(storiesShown)) }
+ assertNotNull(Pocket.homeRecsShown.testGetValue())
+ assertEquals(1, Pocket.homeRecsShown.testGetValue()!!.size)
+ assertNull(Pocket.homeRecsShown.testGetValue()!!.single().extra)
+ }
+
+ @Test
+ fun `WHEN a recommended story is clicked THEN open that story's url using HomeActivity and record telemetry`() {
+ val story = PocketRecommendedStory(
+ title = "",
+ url = "testLink",
+ imageUrl = "",
+ publisher = "",
+ category = "",
+ timeToRead = 0,
+ timesShown = 123,
+ )
+ val homeActivity: HomeActivity = mockk(relaxed = true)
+ val controller = DefaultPocketStoriesController(homeActivity, mockk())
+ assertNull(Pocket.homeRecsStoryClicked.testGetValue())
+
+ controller.handleStoryClicked(story, 1 to 2)
+
+ verify { homeActivity.openToBrowserAndLoad(story.url, true, BrowserDirection.FromHome) }
+
+ assertNotNull(Pocket.homeRecsStoryClicked.testGetValue())
+ val event = Pocket.homeRecsStoryClicked.testGetValue()!!
+ assertEquals(1, event.size)
+ assertTrue(event.single().extra!!.containsKey("position"))
+ assertEquals("1x2", event.single().extra!!["position"])
+ assertTrue(event.single().extra!!.containsKey("times_shown"))
+ assertEquals(story.timesShown.inc().toString(), event.single().extra!!["times_shown"])
+ }
+
+ @Test
+ fun `WHEN a sponsored story is clicked THEN open that story's url using HomeActivity and record telemetry`() {
+ val storyClicked = PocketSponsoredStory(
+ id = 7,
+ title = "",
+ url = "testLink",
+ imageUrl = "",
+ sponsor = "",
+ shim = mockk {
+ every { click } returns "testClickShim"
+ every { impression } returns "testImpressionShim"
+ },
+ priority = 3,
+ caps = mockk(relaxed = true),
+ )
+ val homeActivity: HomeActivity = mockk(relaxed = true)
+ val controller = DefaultPocketStoriesController(homeActivity, mockk())
+ var wasPingSent = false
+ assertNull(Pocket.homeRecsSpocClicked.testGetValue())
+ mockkStatic("mozilla.components.service.pocket.ext.PocketStoryKt") {
+ // Simulate that the story was already shown 2 times.
+ every { storyClicked.getCurrentFlightImpressions() } returns listOf(2L, 3L)
+ // Test that the spoc ping is immediately sent with the needed data.
+ Pings.spoc.testBeforeNextSubmit { reason ->
+ assertEquals(storyClicked.shim.click, Pocket.spocShim.testGetValue())
+ assertEquals(Pings.spocReasonCodes.click.name, reason?.name)
+ wasPingSent = true
+ }
+
+ controller.handleStoryClicked(storyClicked, 2 to 3)
+
+ verify { homeActivity.openToBrowserAndLoad(storyClicked.url, true, BrowserDirection.FromHome) }
+ assertNotNull(Pocket.homeRecsSpocClicked.testGetValue())
+ assertEquals(1, Pocket.homeRecsSpocClicked.testGetValue()!!.size)
+ val data = Pocket.homeRecsSpocClicked.testGetValue()!!.single().extra
+ assertEquals("7", data?.entries?.first { it.key == "spoc_id" }?.value)
+ assertEquals("2x3", data?.entries?.first { it.key == "position" }?.value)
+ assertEquals("3", data?.entries?.first { it.key == "times_shown" }?.value)
+ assertTrue(wasPingSent)
+ }
+ }
+
+ @Test
+ fun `WHEN discover more is clicked then open that using HomeActivity and record telemetry`() {
+ val link = "http://getpocket.com/explore"
+ val homeActivity: HomeActivity = mockk(relaxed = true)
+ val controller = DefaultPocketStoriesController(homeActivity, mockk())
+ assertNull(Pocket.homeRecsDiscoverClicked.testGetValue())
+
+ controller.handleDiscoverMoreClicked(link)
+
+ verify { homeActivity.openToBrowserAndLoad(link, true, BrowserDirection.FromHome) }
+ assertNotNull(Pocket.homeRecsDiscoverClicked.testGetValue())
+ assertEquals(1, Pocket.homeRecsDiscoverClicked.testGetValue()!!.size)
+ assertNull(Pocket.homeRecsDiscoverClicked.testGetValue()!!.single().extra)
+ }
+
+ @Test
+ fun `WHEN learn more is clicked then open that using HomeActivity and record telemetry`() {
+ val link = "https://www.mozilla.org/en-US/firefox/pocket/"
+ val homeActivity: HomeActivity = mockk(relaxed = true)
+ val controller = DefaultPocketStoriesController(homeActivity, mockk())
+ assertNull(Pocket.homeRecsLearnMoreClicked.testGetValue())
+
+ controller.handleLearnMoreClicked(link)
+
+ verify { homeActivity.openToBrowserAndLoad(link, true, BrowserDirection.FromHome) }
+ assertNotNull(Pocket.homeRecsLearnMoreClicked.testGetValue())
+ assertNull(Pocket.homeRecsLearnMoreClicked.testGetValue()!!.single().extra)
+ }
+
+ @Test
+ fun `WHEN a story is clicked THEN its link is opened`() {
+ val story = PocketRecommendedStory("", "url", "", "", "", 0, 0)
+ val homeActivity: HomeActivity = mockk(relaxed = true)
+ val controller = DefaultPocketStoriesController(homeActivity, mockk())
+
+ controller.handleStoryClicked(story, 1 to 2)
+
+ verifyOrder {
+ homeActivity.openToBrowserAndLoad(story.url, true, BrowserDirection.FromHome)
+ }
+ }
+
+ @Test
+ fun `WHEN discover more is clicked THEN its link is opened`() {
+ val link = "https://discoverMore.link"
+ val homeActivity: HomeActivity = mockk(relaxed = true)
+ val controller = DefaultPocketStoriesController(homeActivity, mockk())
+
+ controller.handleDiscoverMoreClicked(link)
+
+ verifyOrder {
+ homeActivity.openToBrowserAndLoad(link, true, BrowserDirection.FromHome)
+ }
+ }
+
+ @Test
+ fun `WHEN learn more link is clicked THEN that link is opened`() {
+ val link = "https://learnMore.link"
+ val homeActivity: HomeActivity = mockk(relaxed = true)
+ val controller = DefaultPocketStoriesController(homeActivity, mockk())
+
+ controller.handleLearnMoreClicked(link)
+
+ verifyOrder {
+ homeActivity.openToBrowserAndLoad(link, true, BrowserDirection.FromHome)
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/privatebrowsing/DefaultPrivateBrowsingControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/privatebrowsing/DefaultPrivateBrowsingControllerTest.kt
new file mode 100644
index 0000000000..2b1bd8cee5
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/privatebrowsing/DefaultPrivateBrowsingControllerTest.kt
@@ -0,0 +1,158 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.privatebrowsing
+
+import androidx.navigation.NavController
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.support.test.ext.joinBlocking
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.BrowserDirection
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.R
+import org.mozilla.fenix.browser.BrowserFragmentDirections
+import org.mozilla.fenix.browser.browsingmode.BrowsingMode
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppAction
+import org.mozilla.fenix.components.appstate.AppState
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.home.privatebrowsing.controller.DefaultPrivateBrowsingController
+import org.mozilla.fenix.utils.Settings
+
+class DefaultPrivateBrowsingControllerTest {
+
+ private val activity: HomeActivity = mockk(relaxed = true)
+ private val appStore: AppStore = mockk(relaxed = true)
+ private val navController: NavController = mockk(relaxed = true)
+ private val settings: Settings = mockk(relaxed = true)
+
+ private lateinit var store: BrowserStore
+ private lateinit var controller: DefaultPrivateBrowsingController
+
+ @Before
+ fun setup() {
+ store = BrowserStore()
+ controller = DefaultPrivateBrowsingController(
+ activity = activity,
+ appStore = appStore,
+ navController = navController,
+ )
+
+ every { appStore.state } returns AppState()
+
+ every { navController.currentDestination } returns mockk {
+ every { id } returns R.id.homeFragment
+ }
+ every { activity.components.settings } returns settings
+ every { activity.settings() } returns settings
+ }
+
+ @Test
+ fun `WHEN private browsing learn more link is clicked THEN open support page in browser`() {
+ val learnMoreURL = "https://support.mozilla.org/en-US/kb/common-myths-about-private-browsing?as=u&utm_source=inproduct"
+
+ controller.handleLearnMoreClicked()
+
+ verify {
+ activity.openToBrowserAndLoad(
+ searchTermOrURL = learnMoreURL,
+ newTab = true,
+ from = BrowserDirection.FromHome,
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN private mode button is selected from home THEN handle mode change`() {
+ every { navController.currentDestination } returns mockk {
+ every { id } returns R.id.homeFragment
+ }
+
+ every { settings.incrementNumTimesPrivateModeOpened() } just Runs
+
+ val newMode = BrowsingMode.Private
+
+ controller.handlePrivateModeButtonClicked(newMode)
+
+ verify {
+ settings.incrementNumTimesPrivateModeOpened()
+ AppAction.ModeChange(newMode)
+ }
+ }
+
+ @Test
+ fun `WHEN private mode is selected on home from behind search THEN handle mode change`() {
+ every { navController.currentDestination } returns mockk {
+ every { id } returns R.id.searchDialogFragment
+ }
+
+ every { settings.incrementNumTimesPrivateModeOpened() } just Runs
+
+ val url = "https://mozilla.org"
+ val tab = createTab(
+ id = "otherTab",
+ url = url,
+ private = false,
+ engineSession = mockk(relaxed = true),
+ )
+ store.dispatch(TabListAction.AddTabAction(tab, select = true)).joinBlocking()
+
+ val newMode = BrowsingMode.Private
+
+ controller.handlePrivateModeButtonClicked(newMode)
+
+ verify {
+ settings.incrementNumTimesPrivateModeOpened()
+ AppAction.ModeChange(newMode)
+ navController.navigate(
+ BrowserFragmentDirections.actionGlobalSearchDialog(
+ sessionId = null,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN private mode is deselected on home from behind search THEN handle mode change`() {
+ every { navController.currentDestination } returns mockk {
+ every { id } returns R.id.searchDialogFragment
+ }
+
+ val url = "https://mozilla.org"
+ val tab = createTab(
+ id = "otherTab",
+ url = url,
+ private = true,
+ engineSession = mockk(relaxed = true),
+ )
+ store.dispatch(TabListAction.AddTabAction(tab, select = true)).joinBlocking()
+
+ val newMode = BrowsingMode.Normal
+
+ controller.handlePrivateModeButtonClicked(newMode)
+
+ verify(exactly = 0) {
+ settings.incrementNumTimesPrivateModeOpened()
+ }
+ verify {
+ appStore.dispatch(
+ AppAction.ModeChange(newMode),
+ )
+ navController.navigate(
+ BrowserFragmentDirections.actionGlobalSearchDialog(
+ sessionId = null,
+ ),
+ )
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentbookmarks/DefaultRecentBookmarksControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentbookmarks/DefaultRecentBookmarksControllerTest.kt
new file mode 100644
index 0000000000..6f03ddbf9a
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentbookmarks/DefaultRecentBookmarksControllerTest.kt
@@ -0,0 +1,153 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.recentbookmarks
+
+import androidx.navigation.NavController
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.appservices.places.BookmarkRoot
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.concept.engine.EngineSession.LoadUrlFlags.Companion.ALLOW_JAVASCRIPT_URL
+import mozilla.components.feature.tabs.TabsUseCases
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import mozilla.telemetry.glean.testing.GleanTestRule
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.BrowserDirection
+import org.mozilla.fenix.GleanMetrics.RecentBookmarks
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.R
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.home.HomeFragmentDirections
+import org.mozilla.fenix.home.recentbookmarks.controller.DefaultRecentBookmarksController
+
+@RunWith(FenixRobolectricTestRunner::class)
+class DefaultRecentBookmarksControllerTest {
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ private val activity: HomeActivity = mockk(relaxed = true)
+ private val navController: NavController = mockk(relaxUnitFun = true)
+ private val selectTabUseCase: TabsUseCases = mockk(relaxed = true)
+ private val browserStore: BrowserStore = mockk(relaxed = true)
+
+ private lateinit var controller: DefaultRecentBookmarksController
+
+ @Before
+ fun setup() {
+ every { activity.openToBrowserAndLoad(any(), any(), any()) } just Runs
+ every { browserStore.state.tabs }.returns(emptyList())
+
+ controller = spyk(
+ DefaultRecentBookmarksController(
+ activity = activity,
+ navController = navController,
+ appStore = mockk(),
+ browserStore = browserStore,
+ selectTabUseCase = selectTabUseCase.selectTab,
+ ),
+ )
+ }
+
+ @Test
+ fun `GIVEN no tabs WHEN a recently saved bookmark is clicked THEN the selected bookmark is opened in a new tab`() {
+ assertNull(RecentBookmarks.bookmarkClicked.testGetValue())
+
+ val bookmark = RecentBookmark(title = null, url = "https://www.example.com")
+ controller.handleBookmarkClicked(bookmark)
+
+ verify {
+ activity.openToBrowserAndLoad(
+ searchTermOrURL = bookmark.url!!,
+ newTab = true,
+ flags = EngineSession.LoadUrlFlags.select(ALLOW_JAVASCRIPT_URL),
+ from = BrowserDirection.FromHome,
+ )
+ }
+ assertNotNull(RecentBookmarks.bookmarkClicked.testGetValue())
+ }
+
+ @Test
+ fun `GIVEN no matching tabs WHEN a recently saved bookmark is clicked THEN the selected bookmark is opened in a new tab`() {
+ assertNull(RecentBookmarks.bookmarkClicked.testGetValue())
+
+ val testTab = createTab("https://www.not_example.com")
+ every { browserStore.state.tabs }.returns(listOf(testTab))
+
+ val bookmark = RecentBookmark(title = null, url = "https://www.example.com")
+ controller.handleBookmarkClicked(bookmark)
+
+ verify {
+ activity.openToBrowserAndLoad(
+ searchTermOrURL = bookmark.url!!,
+ newTab = true,
+ flags = EngineSession.LoadUrlFlags.select(ALLOW_JAVASCRIPT_URL),
+ from = BrowserDirection.FromHome,
+ )
+ }
+ assertNotNull(RecentBookmarks.bookmarkClicked.testGetValue())
+ }
+
+ @Test
+ fun `GIVEN matching tab WHEN a recently saved bookmark is clicked THEN the existing tab is opened`() {
+ assertNull(RecentBookmarks.bookmarkClicked.testGetValue())
+
+ val testUrl = "https://www.example.com"
+ val testTab = createTab(testUrl)
+ every { browserStore.state.tabs }.returns(listOf(testTab))
+
+ val bookmark = RecentBookmark(title = null, url = testUrl)
+ controller.handleBookmarkClicked(bookmark)
+
+ verify {
+ selectTabUseCase.invoke(testTab.id)
+ navController.navigate(R.id.browserFragment)
+ }
+ assertNotNull(RecentBookmarks.bookmarkClicked.testGetValue())
+ }
+
+ @Test
+ fun `WHEN show all recently saved bookmark is clicked THEN the bookmarks root is opened`() = runTestOnMain {
+ assertNull(RecentBookmarks.showAllBookmarks.testGetValue())
+
+ controller.handleShowAllBookmarksClicked()
+
+ val directions = HomeFragmentDirections.actionGlobalBookmarkFragment(BookmarkRoot.Mobile.id)
+ verify {
+ navController.navigate(directions)
+ }
+ assertNotNull(RecentBookmarks.showAllBookmarks.testGetValue())
+ }
+
+ @Test
+ fun `WHEN show all is clicked from behind search dialog THEN open bookmarks root`() {
+ assertNull(RecentBookmarks.showAllBookmarks.testGetValue())
+
+ controller.handleShowAllBookmarksClicked()
+
+ val directions = HomeFragmentDirections.actionGlobalBookmarkFragment(BookmarkRoot.Mobile.id)
+
+ verify {
+ navController.navigate(directions)
+ }
+ assertNotNull(RecentBookmarks.showAllBookmarks.testGetValue())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentbookmarks/RecentBookmarksFeatureTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentbookmarks/RecentBookmarksFeatureTest.kt
new file mode 100644
index 0000000000..74553b9b5b
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentbookmarks/RecentBookmarksFeatureTest.kt
@@ -0,0 +1,73 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.recentbookmarks
+
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import mozilla.components.concept.storage.BookmarkNode
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.middleware.CaptureActionsMiddleware
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppAction
+import org.mozilla.fenix.components.appstate.AppState
+import org.mozilla.fenix.components.bookmarks.BookmarksUseCase
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class RecentBookmarksFeatureTest {
+
+ private val middleware = CaptureActionsMiddleware<AppState, AppAction>()
+ private val appStore = AppStore(middlewares = listOf(middleware))
+ private val bookmarksUseCases: BookmarksUseCase = mockk(relaxed = true)
+ private val bookmark = RecentBookmark(
+ title = null,
+ url = "https://www.example.com",
+ previewImageUrl = null,
+ )
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val testDispatcher = coroutinesTestRule.testDispatcher
+ private val scope = coroutinesTestRule.scope
+
+ @Before
+ fun setup() {
+ coEvery { bookmarksUseCases.retrieveRecentBookmarks() }.coAnswers { listOf(bookmark) }
+ }
+
+ @Test
+ fun `GIVEN no recent bookmarks WHEN feature starts THEN fetch bookmarks and notify store`() =
+ runTestOnMain {
+ val feature = RecentBookmarksFeature(
+ appStore,
+ bookmarksUseCases,
+ scope,
+ testDispatcher,
+ )
+
+ assertEquals(emptyList<BookmarkNode>(), appStore.state.recentBookmarks)
+
+ feature.start()
+
+ advanceUntilIdle()
+ appStore.waitUntilIdle()
+
+ coVerify {
+ bookmarksUseCases.retrieveRecentBookmarks()
+ }
+
+ middleware.assertLastAction(AppAction.RecentBookmarksChange::class) {
+ assertEquals(listOf(bookmark), it.recentBookmarks)
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentsyncedtabs/RecentSyncedTabFeatureTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentsyncedtabs/RecentSyncedTabFeatureTest.kt
new file mode 100644
index 0000000000..198a1e990f
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentsyncedtabs/RecentSyncedTabFeatureTest.kt
@@ -0,0 +1,585 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.recentsyncedtabs
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkConstructor
+import io.mockk.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import mozilla.components.browser.storage.sync.SyncedDeviceTabs
+import mozilla.components.browser.storage.sync.Tab
+import mozilla.components.browser.storage.sync.TabEntry
+import mozilla.components.concept.storage.HistoryStorage
+import mozilla.components.concept.storage.VisitInfo
+import mozilla.components.concept.storage.VisitType
+import mozilla.components.concept.sync.Device
+import mozilla.components.concept.sync.DeviceType
+import mozilla.components.feature.syncedtabs.storage.SyncedTabsStorage
+import mozilla.components.service.fxa.SyncEngine
+import mozilla.components.service.fxa.manager.FxaAccountManager
+import mozilla.components.service.fxa.manager.SyncEnginesStorage
+import mozilla.components.service.fxa.manager.ext.withConstellation
+import mozilla.components.service.fxa.store.Account
+import mozilla.components.service.fxa.store.SyncAction
+import mozilla.components.service.fxa.store.SyncStatus
+import mozilla.components.service.fxa.store.SyncStore
+import mozilla.components.service.fxa.sync.SyncReason
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.telemetry.glean.internal.ErrorType
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.RecentSyncedTabs
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppAction
+
+@RunWith(AndroidJUnit4::class)
+class RecentSyncedTabFeatureTest {
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ private val earliestTime = 100L
+ private val earlierTime = 250L
+ private val timeNow = 500L
+ private val currentDevice = Device(
+ id = "currentId",
+ displayName = "currentDevice",
+ deviceType = DeviceType.MOBILE,
+ isCurrentDevice = true,
+ lastAccessTime = timeNow,
+ capabilities = listOf(),
+ subscriptionExpired = false,
+ subscription = null,
+ )
+ private val deviceAccessed1 = Device(
+ id = "id1",
+ displayName = "device1",
+ deviceType = DeviceType.DESKTOP,
+ isCurrentDevice = false,
+ lastAccessTime = earliestTime,
+ capabilities = listOf(),
+ subscriptionExpired = false,
+ subscription = null,
+ )
+ private val deviceAccessed2 = Device(
+ id = "id2",
+ displayName = "device2",
+ deviceType = DeviceType.DESKTOP,
+ isCurrentDevice = false,
+ lastAccessTime = earlierTime,
+ capabilities = listOf(),
+ subscriptionExpired = false,
+ subscription = null,
+ )
+
+ private val appStore: AppStore = mockk()
+ private val accountManager: FxaAccountManager = mockk(relaxed = true)
+ private val syncedTabsStorage: SyncedTabsStorage = mockk()
+ private val historyStorage: HistoryStorage = mockk()
+
+ private val syncStore = SyncStore()
+
+ private lateinit var feature: RecentSyncedTabFeature
+
+ @Before
+ fun setup() {
+ Dispatchers.setMain(StandardTestDispatcher())
+
+ every { appStore.dispatch(any()) } returns mockk()
+ mockkConstructor(SyncEnginesStorage::class)
+ every { anyConstructed<SyncEnginesStorage>().getStatus() } returns mapOf(
+ SyncEngine.Tabs to true,
+ )
+
+ feature = RecentSyncedTabFeature(
+ context = testContext,
+ appStore = appStore,
+ syncStore = syncStore,
+ accountManager = accountManager,
+ storage = syncedTabsStorage,
+ historyStorage = historyStorage,
+ coroutineScope = TestScope(),
+ )
+ }
+
+ @Test
+ fun `GIVEN account is not available WHEN started THEN nothing is dispatched`() {
+ feature.start()
+
+ verify(exactly = 0) { appStore.dispatch(any()) }
+ }
+
+ @Test
+ fun `GIVEN current tab state is none WHEN account becomes available THEN loading state is dispatched, devices are refreshed, and a sync is started`() = runTest {
+ val account = mockk<Account>()
+ syncStore.setState(account = account)
+
+ every { appStore.state } returns mockk {
+ every { recentSyncedTabState } returns RecentSyncedTabState.None
+ }
+
+ feature.start()
+ runCurrent()
+
+ verify { appStore.dispatch(AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.Loading)) }
+ coVerify { accountManager.withConstellation { refreshDevices() } }
+ coVerify { accountManager.syncNow(reason = SyncReason.User, debounce = true, customEngineSubset = listOf(SyncEngine.Tabs)) }
+ }
+
+ @Test
+ fun `GIVEN current tab state is not none WHEN account becomes available THEN loading state is not dispatched`() = runTest {
+ val account = mockk<Account>()
+ syncStore.setState(account = account)
+
+ every { appStore.state } returns mockk {
+ every { recentSyncedTabState } returns RecentSyncedTabState.Loading
+ }
+
+ feature.start()
+ runCurrent()
+
+ verify(exactly = 0) { appStore.dispatch(AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.Loading)) }
+ }
+
+ @Test
+ fun `GIVEN synced tabs WHEN status becomes idle THEN recent synced tab is dispatched`() = runTest {
+ val account = mockk<Account>()
+ syncStore.setState(account = account)
+ every { appStore.state } returns mockk {
+ every { recentSyncedTabState } returns RecentSyncedTabState.Loading
+ }
+ coEvery { historyStorage.getDetailedVisits(any(), any()) } returns listOf()
+ val activeTab = createActiveTab()
+ coEvery { syncedTabsStorage.getSyncedDeviceTabs() } returns listOf(
+ SyncedDeviceTabs(
+ device = deviceAccessed1,
+ tabs = listOf(activeTab),
+ ),
+ )
+
+ feature.start()
+ syncStore.setState(status = SyncStatus.Idle)
+ runCurrent()
+
+ val expected = listOf(activeTab.toRecentSyncedTab(deviceAccessed1))
+ verify { appStore.dispatch(AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.Success(expected))) }
+ }
+
+ @Test
+ fun `GIVEN loading state has not been dispatched WHEN status becomes idle THEN timing distribution is not recorded`() = runTest {
+ val account = mockk<Account>()
+ syncStore.setState(account = account)
+ every { appStore.state } returns mockk {
+ every { recentSyncedTabState } returns RecentSyncedTabState.Loading
+ }
+ coEvery { historyStorage.getDetailedVisits(any(), any()) } returns listOf()
+ val activeTab = createActiveTab()
+ coEvery { syncedTabsStorage.getSyncedDeviceTabs() } returns listOf(
+ SyncedDeviceTabs(
+ device = deviceAccessed1,
+ tabs = listOf(activeTab),
+ ),
+ )
+
+ feature.start()
+ syncStore.setState(status = SyncStatus.Idle)
+ runCurrent()
+ // this does not trigger a loading state, which should only be shown when tabs are loaded
+ // during app initialization
+ syncStore.setState(status = SyncStatus.Started)
+ runCurrent()
+ syncStore.setState(status = SyncStatus.Idle)
+ runCurrent()
+
+ assertEquals(
+ 0,
+ RecentSyncedTabs.recentSyncedTabTimeToLoad.testGetNumRecordedErrors(
+ ErrorType.INVALID_STATE,
+ ),
+ )
+ }
+
+ @Test
+ fun `GIVEN tabs from remote and current devices WHEN dispatching recent synced tab THEN current device is filtered out of dispatch`() = runTest {
+ val account = mockk<Account>()
+ syncStore.setState(account = account)
+ every { appStore.state } returns mockk {
+ every { recentSyncedTabState } returns RecentSyncedTabState.Loading
+ }
+ coEvery { historyStorage.getDetailedVisits(any(), any()) } returns listOf()
+ val localTab = createActiveTab("local", "https://local.com", null)
+ val remoteTab = createActiveTab("remote", "https://mozilla.org", null)
+ val syncedTabs = listOf(
+ SyncedDeviceTabs(currentDevice, listOf(localTab)),
+ SyncedDeviceTabs(deviceAccessed1, listOf(remoteTab)),
+ )
+ coEvery { syncedTabsStorage.getSyncedDeviceTabs() } returns syncedTabs
+
+ feature.start()
+ syncStore.setState(status = SyncStatus.Idle)
+ runCurrent()
+
+ val expectedTabs = listOf(remoteTab.toRecentSyncedTab(deviceAccessed1))
+ verify {
+ appStore.dispatch(
+ AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.Success(expectedTabs)),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN there are devices with empty tabs list WHEN dispatching recent synced tab THEN devices with empty tabs list are filtered out`() = runTest {
+ val account = mockk<Account>()
+ syncStore.setState(account = account)
+ every { appStore.state } returns mockk {
+ every { recentSyncedTabState } returns RecentSyncedTabState.Loading
+ }
+ coEvery { historyStorage.getDetailedVisits(any(), any()) } returns listOf()
+ val remoteTab = createActiveTab("remote", "https://mozilla.org", null)
+ val syncedTabs = listOf(
+ SyncedDeviceTabs(deviceAccessed2, listOf()),
+ SyncedDeviceTabs(deviceAccessed1, listOf(remoteTab)),
+ )
+ coEvery { syncedTabsStorage.getSyncedDeviceTabs() } returns syncedTabs
+
+ feature.start()
+ syncStore.setState(status = SyncStatus.Idle)
+ runCurrent()
+
+ val expectedTabs = listOf(remoteTab.toRecentSyncedTab(deviceAccessed1))
+ verify {
+ appStore.dispatch(
+ AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.Success(expectedTabs)),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN tabs from different remote devices WHEN dispatching recent synced tab THEN most recently accessed tabs are set in the Success state`() =
+ runTest {
+ val account = mockk<Account>()
+ syncStore.setState(account = account)
+ every { appStore.state } returns mockk {
+ every { recentSyncedTabState } returns RecentSyncedTabState.Loading
+ }
+ coEvery { historyStorage.getDetailedVisits(any(), any()) } returns listOf()
+ val firstDeviceTabs = listOf(
+ createActiveTab("first", "https://local.com", null),
+ createActiveTab("second", "https://github.com", null),
+ )
+ val secondDeviceTabs = listOf(
+ createActiveTab("first", "https://mozilla.org", null),
+ createActiveTab("second", "https://www.mozilla.org/en-US/firefox", null),
+ )
+ val currentTime = System.currentTimeMillis()
+ // Delay used to change last used times of tabs
+ val usedDelay = 5 * 60 * 1000
+ every { firstDeviceTabs[0].lastUsed } returns currentTime
+ every { firstDeviceTabs[1].lastUsed } returns currentTime - 2 * usedDelay
+ every { secondDeviceTabs[0].lastUsed } returns currentTime - usedDelay
+ every { secondDeviceTabs[1].lastUsed } returns currentTime - 3 * usedDelay
+ val syncedTabs = listOf(
+ SyncedDeviceTabs(deviceAccessed1, firstDeviceTabs),
+ SyncedDeviceTabs(deviceAccessed2, secondDeviceTabs),
+ )
+ coEvery { syncedTabsStorage.getSyncedDeviceTabs() } returns syncedTabs
+
+ feature.start()
+ syncStore.setState(status = SyncStatus.Idle)
+ runCurrent()
+
+ // The order of the tabs should be given by the `lastUsed` property
+ val expectedTabs =
+ (firstDeviceTabs + secondDeviceTabs).sortedByDescending { it.lastUsed }.map {
+ if (it in firstDeviceTabs) {
+ it.toRecentSyncedTab(deviceAccessed1)
+ } else {
+ it.toRecentSyncedTab(deviceAccessed2)
+ }
+ }
+ verify {
+ appStore.dispatch(
+ AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.Success(expectedTabs)),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN sync tabs are disabled WHEN dispatching recent synced tab THEN dispatch none`() = runTest {
+ val account = mockk<Account>()
+ syncStore.setState(account = account)
+ every { appStore.state } returns mockk {
+ every { recentSyncedTabState } returns RecentSyncedTabState.Loading
+ }
+ every { anyConstructed<SyncEnginesStorage>().getStatus() } returns mapOf(
+ SyncEngine.Tabs to false,
+ )
+
+ val firstTab = createActiveTab("remote", "https://mozilla.org", null)
+ val syncedTabs = listOf(
+ SyncedDeviceTabs(deviceAccessed1, listOf(firstTab)),
+ )
+ coEvery { syncedTabsStorage.getSyncedDeviceTabs() } returns syncedTabs
+
+ feature.start()
+ syncStore.setState(status = SyncStatus.Idle)
+ runCurrent()
+
+ verify {
+ appStore.dispatch(
+ AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.None),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN synced tab dispatched THEN labeled counter metric recorded with device type`() = runTest {
+ val account = mockk<Account>()
+ syncStore.setState(account = account)
+ every { appStore.state } returns mockk {
+ every { recentSyncedTabState } returns RecentSyncedTabState.Loading
+ }
+ coEvery { historyStorage.getDetailedVisits(any(), any()) } returns listOf()
+ val tab = SyncedDeviceTabs(deviceAccessed1, listOf(createActiveTab()))
+ coEvery { syncedTabsStorage.getSyncedDeviceTabs() } returns listOf(tab)
+
+ feature.start()
+ syncStore.setState(status = SyncStatus.Idle)
+ runCurrent()
+
+ assertEquals(1, RecentSyncedTabs.recentSyncedTabShown["desktop"].testGetValue())
+ }
+
+ @Test
+ fun `WHEN synced tab dispatched THEN load time metric recorded`() = runTest {
+ val account = mockk<Account>()
+ syncStore.setState(account = account)
+ every { appStore.state } returns mockk {
+ every { recentSyncedTabState } returns RecentSyncedTabState.None
+ }
+ coEvery { historyStorage.getDetailedVisits(any(), any()) } returns listOf()
+ val tab = SyncedDeviceTabs(deviceAccessed1, listOf(createActiveTab()))
+ coEvery { syncedTabsStorage.getSyncedDeviceTabs() } returns listOf(tab)
+
+ feature.start()
+ syncStore.setState(status = SyncStatus.Idle)
+ runCurrent()
+
+ assertNotNull(RecentSyncedTabs.recentSyncedTabTimeToLoad.testGetValue())
+ }
+
+ @Test
+ fun `GIVEN that the dispatched tab was the last dispatched tab WHEN dispatched THEN recorded as stale`() = runTest {
+ val account = mockk<Account>()
+ syncStore.setState(account = account)
+ every { appStore.state } returns mockk {
+ every { recentSyncedTabState } returns RecentSyncedTabState.None
+ }
+ coEvery { historyStorage.getDetailedVisits(any(), any()) } returns listOf()
+ val tab = SyncedDeviceTabs(deviceAccessed1, listOf(createActiveTab()))
+ coEvery { syncedTabsStorage.getSyncedDeviceTabs() } returns listOf(tab)
+
+ feature.start()
+ syncStore.setState(status = SyncStatus.Idle)
+ runCurrent()
+ syncStore.setState(status = SyncStatus.Started)
+ runCurrent()
+ syncStore.setState(status = SyncStatus.Idle)
+ runCurrent()
+
+ assertEquals(1, RecentSyncedTabs.latestSyncedTabIsStale.testGetValue())
+ }
+
+ @Test
+ fun `GIVEN that the dispatched tab was not the last dispatched tab WHEN dispatched THEN not recorded as stale`() = runTest {
+ val account = mockk<Account>()
+ syncStore.setState(account = account)
+ every { appStore.state } returns mockk {
+ every { recentSyncedTabState } returns RecentSyncedTabState.None
+ }
+ val tabs1 = listOf(SyncedDeviceTabs(deviceAccessed1, listOf(createActiveTab())))
+ val tabs2 = listOf(SyncedDeviceTabs(deviceAccessed2, listOf(createActiveTab())))
+ coEvery { syncedTabsStorage.getSyncedDeviceTabs() } returnsMany listOf(tabs1, tabs2)
+
+ feature.start()
+ syncStore.setState(status = SyncStatus.Idle)
+ runCurrent()
+ syncStore.setState(status = SyncStatus.Started)
+ runCurrent()
+ syncStore.setState(status = SyncStatus.Idle)
+ runCurrent()
+
+ assertNull(RecentSyncedTabs.latestSyncedTabIsStale.testGetValue())
+ }
+
+ @Test
+ fun `GIVEN current tab state is loading WHEN error is observed THEN tab state is dispatched as none`() = runTest {
+ val account = mockk<Account>()
+ syncStore.setState(account = account)
+ every { appStore.state } returns mockk {
+ every { recentSyncedTabState } returnsMany listOf(
+ RecentSyncedTabState.None,
+ RecentSyncedTabState.Loading,
+ )
+ }
+
+ feature.start()
+ runCurrent()
+ syncStore.setState(status = SyncStatus.Error)
+ runCurrent()
+
+ verify { appStore.dispatch(AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.None)) }
+ }
+
+ @Test
+ fun `GIVEN current tab state is not loading WHEN error is observed THEN nothing is dispatched`() = runTest {
+ feature.start()
+ syncStore.setState(status = SyncStatus.Error)
+ runCurrent()
+
+ verify(exactly = 0) { appStore.dispatch(AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.None)) }
+ }
+
+ @Test
+ fun `GIVEN that a tab has been dispatched WHEN LoggedOut is observed THEN tab state is dispatched as none`() = runTest {
+ val account = mockk<Account>()
+ syncStore.setState(account = account)
+ every { appStore.state } returns mockk {
+ every { recentSyncedTabState } returns RecentSyncedTabState.None
+ }
+ coEvery { historyStorage.getDetailedVisits(any(), any()) } returns listOf()
+ val tab = createActiveTab()
+ coEvery { syncedTabsStorage.getSyncedDeviceTabs() } returns listOf(
+ SyncedDeviceTabs(deviceAccessed1, listOf(tab)),
+ )
+
+ feature.start()
+ runCurrent()
+ syncStore.setState(status = SyncStatus.Idle)
+ runCurrent()
+ syncStore.setState(status = SyncStatus.LoggedOut)
+ runCurrent()
+
+ val expected = listOf(tab.toRecentSyncedTab(deviceAccessed1))
+ verify { appStore.dispatch(AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.Success(expected))) }
+ verify { appStore.dispatch(AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.None)) }
+ }
+
+ @Test
+ fun `GIVEN history entry contains synced tab host and has a preview image URL WHEN dispatched THEN preview url is included`() = runTest {
+ val account = mockk<Account>()
+ syncStore.setState(account = account)
+ every { appStore.state } returns mockk {
+ every { recentSyncedTabState } returns RecentSyncedTabState.Loading
+ }
+ val activeTab = createActiveTab()
+ coEvery { syncedTabsStorage.getSyncedDeviceTabs() } returns listOf(
+ SyncedDeviceTabs(
+ device = deviceAccessed1,
+ tabs = listOf(activeTab),
+ ),
+ )
+ val longerThanSyncedTabUrl = activeTab.active().url + "/some/more/paths"
+ val previewUrl = "preview"
+ coEvery { historyStorage.getDetailedVisits(any(), any()) } returns listOf(
+ activeTab.toVisitInfo(longerThanSyncedTabUrl, previewUrl),
+ )
+
+ feature.start()
+ syncStore.setState(status = SyncStatus.Idle)
+ runCurrent()
+
+ val expected = listOf(activeTab.toRecentSyncedTab(deviceAccessed1, previewUrl))
+ verify { appStore.dispatch(AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.Success(expected))) }
+ }
+
+ @Test
+ fun `GIVEN history entry contains synced tab host but has no preview image URL WHEN dispatched THEN preview url is not included`() = runTest {
+ val account = mockk<Account>()
+ syncStore.setState(account = account)
+ every { appStore.state } returns mockk {
+ every { recentSyncedTabState } returns RecentSyncedTabState.Loading
+ }
+ val activeTab = createActiveTab()
+ coEvery { syncedTabsStorage.getSyncedDeviceTabs() } returns listOf(
+ SyncedDeviceTabs(
+ device = deviceAccessed1,
+ tabs = listOf(activeTab),
+ ),
+ )
+ val longerThanSyncedTabUrl = activeTab.active().url + "/some/more/paths"
+ coEvery { historyStorage.getDetailedVisits(any(), any()) } returns listOf(
+ activeTab.toVisitInfo(longerThanSyncedTabUrl, null),
+ )
+
+ feature.start()
+ syncStore.setState(status = SyncStatus.Idle)
+ runCurrent()
+
+ val expected = listOf(activeTab.toRecentSyncedTab(deviceAccessed1, null))
+ verify { appStore.dispatch(AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.Success(expected))) }
+ }
+
+ private fun createActiveTab(
+ title: String = "title",
+ url: String = "url",
+ iconUrl: String? = null,
+ ): Tab {
+ val tab = mockk<Tab>()
+ val tabEntry = TabEntry(title, url, iconUrl)
+ every { tab.active() } returns tabEntry
+ every { tab.lastUsed } returns System.currentTimeMillis()
+ return tab
+ }
+
+ private fun Tab.toRecentSyncedTab(
+ device: Device,
+ previewImageUrl: String? = null,
+ ) = RecentSyncedTab(
+ deviceDisplayName = device.displayName,
+ deviceType = device.deviceType,
+ title = this.active().title,
+ url = this.active().url,
+ previewImageUrl = previewImageUrl,
+ )
+
+ private fun SyncStore.setState(
+ status: SyncStatus? = null,
+ account: Account? = null,
+ ) {
+ status?.let {
+ this.dispatch(SyncAction.UpdateSyncStatus(status))
+ }
+ account?.let {
+ this.dispatch(SyncAction.UpdateAccount(account))
+ }
+ this.waitUntilIdle()
+ }
+
+ private fun Tab.toVisitInfo(url: String, previewUrl: String?) = VisitInfo(
+ title = this.active().title,
+ url = url,
+ visitTime = 0L,
+ visitType = VisitType.TYPED,
+ previewImageUrl = previewUrl,
+ isRemote = false,
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentsyncedtabs/controller/DefaultRecentSyncedTabControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentsyncedtabs/controller/DefaultRecentSyncedTabControllerTest.kt
new file mode 100644
index 0000000000..3fac7ee82a
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentsyncedtabs/controller/DefaultRecentSyncedTabControllerTest.kt
@@ -0,0 +1,196 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.recentsyncedtabs.controller
+
+import androidx.navigation.NavController
+import androidx.navigation.NavDirections
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.runs
+import io.mockk.verify
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.ContentState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.sync.DeviceType
+import mozilla.components.feature.tabs.TabsUseCases
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.telemetry.glean.testing.GleanTestRule
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.RecentSyncedTabs
+import org.mozilla.fenix.R
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppAction
+import org.mozilla.fenix.home.HomeFragmentDirections
+import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTab
+import org.mozilla.fenix.tabstray.Page
+import org.mozilla.fenix.tabstray.TabsTrayAccessPoint
+
+@RunWith(AndroidJUnit4::class)
+class DefaultRecentSyncedTabControllerTest {
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ private val tabsUseCases: TabsUseCases = mockk()
+ private val navController: NavController = mockk()
+ private val appStore: AppStore = mockk(relaxed = true)
+ private val accessPoint = TabsTrayAccessPoint.HomeRecentSyncedTab
+
+ private lateinit var controller: RecentSyncedTabController
+
+ @Before
+ fun setup() {
+ controller = DefaultRecentSyncedTabController(
+ tabsUseCase = tabsUseCases,
+ navController = navController,
+ accessPoint = accessPoint,
+ appStore = appStore,
+ )
+ }
+
+ @Test
+ fun `WHEN synced tab clicked THEN new tab added and navigate to browser`() {
+ val url = "url"
+ val nonSyncId = "different id"
+ val tab = RecentSyncedTab(
+ deviceDisplayName = "display",
+ deviceType = DeviceType.DESKTOP,
+ title = "title",
+ url = url,
+ previewImageUrl = null,
+ )
+ val store = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(
+ TabSessionState(
+ id = nonSyncId,
+ content = ContentState(url = "different url", private = false),
+ ),
+ ),
+ selectedTabId = nonSyncId,
+ ),
+ )
+ val selectOrAddTabUseCase = TabsUseCases.SelectOrAddUseCase(store)
+
+ every { tabsUseCases.selectOrAddTab } returns selectOrAddTabUseCase
+ every { navController.navigate(any<Int>()) } just runs
+
+ controller.handleRecentSyncedTabClick(tab)
+
+ store.waitUntilIdle()
+ assertNotEquals(nonSyncId, store.state.selectedTabId)
+ assertEquals(2, store.state.tabs.size)
+ verify { navController.navigate(R.id.browserFragment) }
+ }
+
+ @Test
+ fun `GIVEN synced tab is already open WHEN clicked THEN tab is re-opened and browser navigated`() {
+ val url = "url"
+ val syncId = "id"
+ val nonSyncId = "different id"
+ val tab = RecentSyncedTab(
+ deviceDisplayName = "display",
+ deviceType = DeviceType.DESKTOP,
+ title = "title",
+ url = url,
+ previewImageUrl = null,
+ )
+ val store = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(
+ TabSessionState(
+ id = syncId,
+ content = ContentState(url = url, private = false),
+ ),
+ TabSessionState(
+ id = nonSyncId,
+ content = ContentState(url = "different url", private = false),
+ ),
+ ),
+ selectedTabId = nonSyncId,
+ ),
+ )
+ val selectOrAddTabUseCase = TabsUseCases.SelectOrAddUseCase(store)
+
+ every { tabsUseCases.selectOrAddTab } returns selectOrAddTabUseCase
+ every { navController.navigate(any<Int>()) } just runs
+
+ controller.handleRecentSyncedTabClick(tab)
+
+ store.waitUntilIdle()
+ assertEquals(syncId, store.state.selectedTabId)
+ assertEquals(2, store.state.tabs.size)
+ verify { navController.navigate(R.id.browserFragment) }
+ }
+
+ @Test
+ fun `WHEN synced tab show all clicked THEN navigate to synced tabs tray`() {
+ every { navController.navigate(any<NavDirections>()) } just runs
+
+ controller.handleSyncedTabShowAllClicked()
+
+ verify {
+ navController.navigate(
+ HomeFragmentDirections.actionGlobalTabsTrayFragment(
+ page = Page.SyncedTabs,
+ accessPoint = accessPoint,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN synced tab clicked THEN metric counter labeled by device type is incremented`() {
+ val url = "https://mozilla.org"
+ val deviceType = DeviceType.DESKTOP
+ val tab = RecentSyncedTab(
+ deviceDisplayName = "display",
+ deviceType = deviceType,
+ title = "title",
+ url = url,
+ previewImageUrl = null,
+ )
+
+ every { tabsUseCases.selectOrAddTab } returns mockk(relaxed = true)
+ every { navController.navigate(any<Int>()) } just runs
+
+ controller.handleRecentSyncedTabClick(tab)
+
+ assertEquals(1, RecentSyncedTabs.recentSyncedTabOpened["desktop"].testGetValue())
+ }
+
+ @Test
+ fun `WHEN synced tab show all clicked THEN metric counter is incremented`() {
+ every { navController.navigate(any<NavDirections>()) } just runs
+
+ controller.handleSyncedTabShowAllClicked()
+
+ assertEquals(1, RecentSyncedTabs.showAllSyncedTabsClicked.testGetValue())
+ }
+
+ @Test
+ fun `WHEN synced tab is removed from homescreen THEN RemoveRecentSyncedTab action is dispatched`() {
+ val tab = RecentSyncedTab(
+ deviceDisplayName = "display",
+ deviceType = DeviceType.DESKTOP,
+ title = "title",
+ url = "https://mozilla.org",
+ previewImageUrl = null,
+ )
+
+ controller.handleRecentSyncedTabRemoved(tab)
+
+ verify { appStore.dispatch(AppAction.RemoveRecentSyncedTab(tab)) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recenttabs/controller/RecentTabControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recenttabs/controller/RecentTabControllerTest.kt
new file mode 100644
index 0000000000..7e7a57bc7d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recenttabs/controller/RecentTabControllerTest.kt
@@ -0,0 +1,146 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.recenttabs.controller
+
+import androidx.navigation.NavController
+import androidx.navigation.NavDirections
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.LastMediaAccessState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.feature.tabs.TabsUseCases
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.telemetry.glean.testing.GleanTestRule
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.RecentTabs
+import org.mozilla.fenix.R
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(FenixRobolectricTestRunner::class)
+class RecentTabControllerTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ private val navController: NavController = mockk(relaxed = true)
+ private val selectTabUseCase: TabsUseCases = mockk(relaxed = true)
+ private val appStore: AppStore = mockk()
+
+ private lateinit var store: BrowserStore
+
+ private lateinit var controller: DefaultRecentTabsController
+
+ @Before
+ fun setup() {
+ store = BrowserStore(
+ BrowserState(),
+ )
+ controller = spyk(
+ DefaultRecentTabsController(
+ selectTabUseCase = selectTabUseCase.selectTab,
+ navController = navController,
+ appStore = appStore,
+ ),
+ )
+ }
+
+ @Test
+ fun handleRecentTabClicked() {
+ assertNull(RecentTabs.recentTabOpened.testGetValue())
+
+ every { navController.currentDestination } returns mockk {
+ every { id } returns R.id.homeFragment
+ }
+
+ val tab = createTab(
+ url = "https://mozilla.org",
+ title = "Mozilla",
+ )
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ store.dispatch(TabListAction.SelectTabAction(tab.id)).joinBlocking()
+
+ controller.handleRecentTabClicked(tab.id)
+
+ verify {
+ selectTabUseCase.selectTab.invoke(tab.id)
+ navController.navigate(R.id.browserFragment)
+ }
+ assertNotNull(RecentTabs.recentTabOpened.testGetValue())
+ }
+
+ @Test
+ fun handleRecentTabClickedForMediaTab() {
+ assertNull(RecentTabs.recentTabOpened.testGetValue())
+
+ every { navController.currentDestination } returns mockk {
+ every { id } returns R.id.homeFragment
+ }
+
+ val inProgressMediaTab = createTab(
+ url = "mediaUrl",
+ id = "2",
+ lastMediaAccessState = LastMediaAccessState("https://mozilla.com", 123, true),
+ )
+
+ store.dispatch(TabListAction.AddTabAction(inProgressMediaTab)).joinBlocking()
+ store.dispatch(TabListAction.SelectTabAction(inProgressMediaTab.id)).joinBlocking()
+
+ controller.handleRecentTabClicked(inProgressMediaTab.id)
+
+ verify {
+ selectTabUseCase.selectTab.invoke(inProgressMediaTab.id)
+ navController.navigate(R.id.browserFragment)
+ }
+ assertNotNull(RecentTabs.recentTabOpened.testGetValue())
+ }
+
+ @Test
+ fun handleRecentTabShowAllClickedFromHome() {
+ assertNull(RecentTabs.showAllClicked.testGetValue())
+
+ controller.handleRecentTabShowAllClicked()
+
+ verify {
+ navController.navigate(
+ match<NavDirections> { it.actionId == R.id.action_global_tabsTrayFragment },
+ )
+ }
+
+ assertNotNull(RecentTabs.showAllClicked.testGetValue())
+ }
+
+ @Test
+ fun handleRecentTabShowAllClickedFromSearchDialog() {
+ assertNull(RecentTabs.showAllClicked.testGetValue())
+
+ controller.handleRecentTabShowAllClicked()
+
+ verify {
+ navController.navigate(
+ match<NavDirections> { it.actionId == R.id.action_global_tabsTrayFragment },
+ )
+ }
+
+ assertNotNull(RecentTabs.showAllClicked.testGetValue())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentvisits/HistoryMetadataMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentvisits/HistoryMetadataMiddlewareTest.kt
new file mode 100644
index 0000000000..5a0514631a
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentvisits/HistoryMetadataMiddlewareTest.kt
@@ -0,0 +1,797 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.recentvisits
+
+import io.mockk.Called
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.action.EngineAction
+import mozilla.components.browser.state.action.HistoryMetadataAction
+import mozilla.components.browser.state.action.MediaSessionAction
+import mozilla.components.browser.state.action.SearchAction
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.engine.EngineMiddleware
+import mozilla.components.browser.state.search.SearchEngine
+import mozilla.components.browser.state.selector.findTab
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.history.HistoryItem
+import mozilla.components.concept.storage.HistoryMetadataKey
+import mozilla.components.support.test.ext.joinBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.historymetadata.HistoryMetadataMiddleware
+import org.mozilla.fenix.historymetadata.HistoryMetadataService
+
+@RunWith(FenixRobolectricTestRunner::class)
+class HistoryMetadataMiddlewareTest {
+
+ private lateinit var store: BrowserStore
+ private lateinit var middleware: HistoryMetadataMiddleware
+ private lateinit var service: HistoryMetadataService
+
+ @Before
+ fun setUp() {
+ service = mockk(relaxed = true)
+ middleware = HistoryMetadataMiddleware(service)
+ store = BrowserStore(
+ middleware = listOf(middleware) + EngineMiddleware.create(engine = mockk()),
+ initialState = BrowserState(),
+ )
+ }
+
+ @Test
+ fun `GIVEN normal tab WHEN history is updated THEN meta data is also recorded`() {
+ val tab = createTab("https://mozilla.org")
+
+ val expectedKey = HistoryMetadataKey(url = tab.content.url)
+ every { service.createMetadata(any(), any()) } returns expectedKey
+
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ verify(exactly = 1) { service.createMetadata(tab) }
+
+ store.dispatch(ContentAction.UpdateHistoryStateAction(tab.id, emptyList(), currentIndex = 0)).joinBlocking()
+ val capturedTab = slot<TabSessionState>()
+ verify(exactly = 1) { service.createMetadata(capture(capturedTab)) }
+
+ // Not recording if url didn't change.
+ store.dispatch(ContentAction.UpdateHistoryStateAction(tab.id, emptyList(), currentIndex = 0)).joinBlocking()
+ verify(exactly = 1) { service.createMetadata(capture(capturedTab)) }
+
+ assertEquals(tab.id, capturedTab.captured.id)
+ assertEquals(expectedKey, store.state.findTab(tab.id)?.historyMetadata)
+
+ // Now, test that we'll record metadata for the same tab after url is changed.
+ store.dispatch(ContentAction.UpdateUrlAction(tab.id, "https://firefox.com")).joinBlocking()
+ store.dispatch(ContentAction.UpdateHistoryStateAction(tab.id, emptyList(), currentIndex = 0)).joinBlocking()
+
+ val capturedTabs = mutableListOf<TabSessionState>()
+ verify(exactly = 2) { service.createMetadata(capture(capturedTabs)) }
+
+ assertEquals(2, capturedTabs.size)
+
+ capturedTabs[0].apply {
+ assertEquals(tab.id, id)
+ }
+
+ assertEquals(expectedKey, store.state.findTab(tab.id)?.historyMetadata)
+ }
+
+ @Test
+ fun `GIVEN normal tab has parent with session search terms WHEN history metadata is recorded THEN search terms and referrer url are provided`() {
+ val parentTab = createTab("https://google.com?q=mozilla+website", searchTerms = "mozilla website")
+ val tab = createTab("https://mozilla.org", parent = parentTab)
+ store.dispatch(TabListAction.AddTabAction(parentTab)).joinBlocking()
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+
+ store.dispatch(ContentAction.UpdateHistoryStateAction(tab.id, emptyList(), currentIndex = 0)).joinBlocking()
+ verify {
+ service.createMetadata(any(), eq("mozilla website"), eq("https://google.com?q=mozilla+website"))
+ }
+ }
+
+ @Test
+ fun `GIVEN normal tab has search results parent without session search terms WHEN history metadata is recorded THEN search terms and referrer url are provided`() {
+ setupGoogleSearchEngine()
+
+ val parentTab = createTab("https://google.com?q=mozilla+website")
+ val tab = createTab("https://mozilla.org", parent = parentTab)
+ store.dispatch(TabListAction.AddTabAction(parentTab)).joinBlocking()
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+
+ store.dispatch(ContentAction.UpdateHistoryStateAction(tab.id, emptyList(), currentIndex = 0)).joinBlocking()
+ verify {
+ service.createMetadata(any(), eq("mozilla website"), eq("https://google.com?q=mozilla+website"))
+ }
+ }
+
+ @Test
+ fun `GIVEN normal tab is a search engine result page WHEN history metadata is recorded THEN search terms are provided`() {
+ service = TestingMetadataService()
+ middleware = HistoryMetadataMiddleware(service)
+ val tab = createTab("about:blank")
+ store = BrowserStore(
+ middleware = listOf(middleware) + EngineMiddleware.create(engine = mockk()),
+ initialState = BrowserState(
+ tabs = listOf(tab),
+ ),
+ )
+ setupGoogleSearchEngine()
+
+ val serpUrl = "https://google.com?q=mozilla+website"
+ store.dispatch(EngineAction.LoadUrlAction(tab.id, serpUrl)).joinBlocking()
+ store.dispatch(ContentAction.UpdateUrlAction(tab.id, serpUrl)).joinBlocking()
+ store.dispatch(ContentAction.UpdateHistoryStateAction(tab.id, listOf(HistoryItem("Google Search", serpUrl)), currentIndex = 0)).joinBlocking()
+ with((service as TestingMetadataService).createdMetadata) {
+ assertEquals(1, this.count())
+ assertEquals("https://google.com?q=mozilla+website", this[0].url)
+ assertEquals("mozilla website", this[0].searchTerm)
+ assertNull(this[0].referrerUrl)
+ }
+ }
+
+ @Test
+ fun `GIVEN normal tab navigates to search engine result page WHEN history metadata is recorded THEN search terms are provided`() {
+ service = TestingMetadataService()
+ middleware = HistoryMetadataMiddleware(service)
+ val tab = createTab("https://google.com")
+ store = BrowserStore(
+ middleware = listOf(middleware) + EngineMiddleware.create(engine = mockk()),
+ initialState = BrowserState(
+ tabs = listOf(tab),
+ ),
+ )
+ setupGoogleSearchEngine()
+
+ val serpUrl = "https://google.com?q=mozilla+website"
+ store.dispatch(ContentAction.UpdateUrlAction(tab.id, serpUrl)).joinBlocking()
+ store.dispatch(ContentAction.UpdateHistoryStateAction(tab.id, listOf(HistoryItem("Google Search", "https://google.com"), HistoryItem("Google Search", serpUrl)), currentIndex = 1)).joinBlocking()
+ with((service as TestingMetadataService).createdMetadata) {
+ assertEquals(1, this.count())
+ assertEquals("https://google.com?q=mozilla+website", this[0].url)
+ assertEquals("mozilla website", this[0].searchTerm)
+ assertNull(this[0].referrerUrl)
+ }
+ }
+
+ @Test
+ fun `GIVEN tab opened as new tab from a search page WHEN search page navigates away THEN redirecting or navigating in tab retains original search terms`() {
+ service = TestingMetadataService()
+ middleware = HistoryMetadataMiddleware(service)
+ store = BrowserStore(
+ middleware = listOf(middleware) + EngineMiddleware.create(engine = mockk()),
+ initialState = BrowserState(),
+ )
+ setupGoogleSearchEngine()
+
+ val parentTab = createTab("https://google.com?q=mozilla+website", searchTerms = "mozilla website")
+ val tab = createTab("https://google.com?url=https://mozilla.org", parent = parentTab)
+ store.dispatch(TabListAction.AddTabAction(parentTab, select = true)).joinBlocking()
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+
+ with((service as TestingMetadataService).createdMetadata) {
+ assertEquals(2, this.count())
+ assertEquals("https://google.com?q=mozilla+website", this[0].url)
+ assertEquals("mozilla website", this[0].searchTerm)
+ assertNull(this[0].referrerUrl)
+
+ assertEquals("https://google.com?url=https://mozilla.org", this[1].url)
+ assertEquals("mozilla website", this[1].searchTerm)
+ assertEquals("https://google.com?q=mozilla+website", this[1].referrerUrl)
+ }
+
+ // Both tabs load.
+ store.dispatch(ContentAction.UpdateHistoryStateAction(parentTab.id, listOf(HistoryItem("Google - mozilla website", "https://google.com?q=mozilla+website")), 0)).joinBlocking()
+ store.dispatch(ContentAction.UpdateHistoryStateAction(tab.id, listOf(HistoryItem("", "https://google.com?url=mozilla+website")), currentIndex = 0)).joinBlocking()
+ with((service as TestingMetadataService).createdMetadata) {
+ assertEquals(2, this.count())
+ }
+
+ // Parent navigates away.
+ store.dispatch(ContentAction.UpdateUrlAction(parentTab.id, "https://firefox.com")).joinBlocking()
+ store.dispatch(ContentAction.UpdateSearchTermsAction(parentTab.id, "")).joinBlocking()
+ store.dispatch(ContentAction.UpdateHistoryStateAction(parentTab.id, listOf(HistoryItem("Google - mozilla website", "https://google.com?q=mozilla+website"), HistoryItem("Firefox", "https://firefox.com")), 1)).joinBlocking()
+ with((service as TestingMetadataService).createdMetadata) {
+ assertEquals(3, this.count())
+ assertEquals("https://firefox.com", this[2].url)
+ assertEquals("mozilla website", this[2].searchTerm)
+ assertEquals("https://google.com?q=mozilla+website", this[2].referrerUrl)
+ }
+
+ // Redirect the child tab (url changed, history stack has single item).
+ store.dispatch(ContentAction.UpdateUrlAction(tab.id, "https://mozilla.org")).joinBlocking()
+ store.dispatch(ContentAction.UpdateHistoryStateAction(tab.id, listOf(HistoryItem("Mozilla", "https://mozilla.org")), currentIndex = 0)).joinBlocking()
+ val tab2 = store.state.findTab(tab.id)!!
+ assertEquals("https://mozilla.org", tab2.content.url)
+ with((service as TestingMetadataService).createdMetadata) {
+ assertEquals(4, this.count())
+ assertEquals("https://mozilla.org", this[3].url)
+ assertEquals("mozilla website", this[3].searchTerm)
+ assertEquals("https://google.com?q=mozilla+website", this[3].referrerUrl)
+ }
+
+ // Navigate the child tab.
+ store.dispatch(ContentAction.UpdateUrlAction(tab.id, "https://mozilla.org/manifesto")).joinBlocking()
+ store.dispatch(ContentAction.UpdateHistoryStateAction(tab.id, listOf(HistoryItem("Mozilla", "https://mozilla.org"), HistoryItem("Mozilla Manifesto", "https://mozilla.org/manifesto")), currentIndex = 1)).joinBlocking()
+ val tab3 = store.state.findTab(tab.id)!!
+ assertEquals("https://mozilla.org/manifesto", tab3.content.url)
+
+ with((service as TestingMetadataService).createdMetadata) {
+ assertEquals(5, this.count())
+ assertEquals("https://mozilla.org/manifesto", this[4].url)
+ assertEquals("mozilla website", this[4].searchTerm)
+ assertEquals("https://mozilla.org", this[4].referrerUrl)
+ }
+ }
+
+ @Test
+ fun `GIVEN tab opened as new tab from a search page WHEN it loads while parent navigates to a result THEN parent will retain its search terms`() {
+ service = TestingMetadataService()
+ middleware = HistoryMetadataMiddleware(service)
+ store = BrowserStore(
+ middleware = listOf(middleware) + EngineMiddleware.create(engine = mockk()),
+ initialState = BrowserState(),
+ )
+ setupGoogleSearchEngine()
+
+ val parentTab = createTab("https://google.com?q=mozilla+website", searchTerms = "mozilla website")
+ val tab = createTab("https://google.com?url=https://mozilla.org", parent = parentTab)
+ store.dispatch(TabListAction.AddTabAction(parentTab, select = true)).joinBlocking()
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+
+ with((service as TestingMetadataService).createdMetadata) {
+ assertEquals(2, this.count())
+ assertEquals("https://google.com?q=mozilla+website", this[0].url)
+ assertEquals("mozilla website", this[0].searchTerm)
+ assertNull(this[0].referrerUrl)
+
+ assertEquals("https://google.com?url=https://mozilla.org", this[1].url)
+ assertEquals("mozilla website", this[1].searchTerm)
+ assertEquals("https://google.com?q=mozilla+website", this[1].referrerUrl)
+ }
+
+ // Parent tab loads.
+ store.dispatch(ContentAction.UpdateHistoryStateAction(parentTab.id, listOf(HistoryItem("Google - mozilla website", "https://google.com?q=mozilla+website")), 0)).joinBlocking()
+ with((service as TestingMetadataService).createdMetadata) {
+ assertEquals(2, this.count())
+ }
+
+ // Simulate a state where search metadata is missing for the child tab.
+ store.dispatch(HistoryMetadataAction.SetHistoryMetadataKeyAction(tab.id, HistoryMetadataKey("https://google.com?url=https://mozilla.org", null, null))).joinBlocking()
+
+ // Parent navigates away, while the child starts loading. A mostly realistic sequence of events...
+ store.dispatch(ContentAction.UpdateUrlAction(parentTab.id, "https://firefox.com")).joinBlocking()
+ store.dispatch(ContentAction.UpdateSearchTermsAction(parentTab.id, "")).joinBlocking()
+ store.dispatch(EngineAction.LoadUrlAction(tab.id, "https://google.com?url=https://mozilla.org")).joinBlocking()
+ store.dispatch(ContentAction.UpdateHistoryStateAction(tab.id, listOf(HistoryItem("", "https://google.com?url=https://mozilla.org")), currentIndex = 0)).joinBlocking()
+ store.dispatch(ContentAction.UpdateUrlAction(tab.id, "https://mozilla.org")).joinBlocking()
+ store.dispatch(ContentAction.UpdateHistoryStateAction(tab.id, listOf(HistoryItem("Mozilla", "https://mozilla.org")), currentIndex = 0)).joinBlocking()
+ store.dispatch(ContentAction.UpdateHistoryStateAction(parentTab.id, listOf(HistoryItem("Google - mozilla website", "https://google.com?q=mozilla+website"), HistoryItem("Firefox", "https://firefox.com")), 1)).joinBlocking()
+
+ with((service as TestingMetadataService).createdMetadata) {
+ assertEquals(4, this.count())
+ assertEquals("https://firefox.com", this[3].url)
+ assertEquals("mozilla website", this[3].searchTerm)
+ assertEquals("https://google.com?q=mozilla+website", this[3].referrerUrl)
+
+ assertEquals("https://google.com?url=https://mozilla.org", this[1].url)
+ assertEquals("mozilla website", this[1].searchTerm)
+ assertEquals("https://google.com?q=mozilla+website", this[1].referrerUrl)
+
+ assertEquals("https://mozilla.org", this[2].url)
+ assertEquals("mozilla website", this[2].searchTerm)
+ // This is suspect. The parent tab switched away right before the child loaded, so the
+ // referrer here is potentially bogus.
+ assertEquals("https://firefox.com", this[2].referrerUrl)
+ }
+ }
+
+ @Test
+ fun `GIVEN tab with search terms WHEN subsequent direct load occurs THEN search terms are not retained`() {
+ service = TestingMetadataService()
+ middleware = HistoryMetadataMiddleware(service)
+ store = BrowserStore(
+ middleware = listOf(middleware) + EngineMiddleware.create(engine = mockk()),
+ initialState = BrowserState(),
+ )
+ setupGoogleSearchEngine()
+
+ val parentTab = createTab("https://google.com?q=mozilla+website", searchTerms = "mozilla website")
+ val tab = createTab("https://google.com?url=https://mozilla.org", parent = parentTab)
+ store.dispatch(TabListAction.AddTabAction(parentTab, select = true)).joinBlocking()
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+
+ with((service as TestingMetadataService).createdMetadata) {
+ assertEquals(2, this.count())
+ assertEquals("https://google.com?q=mozilla+website", this[0].url)
+ assertEquals("mozilla website", this[0].searchTerm)
+ assertNull(this[0].referrerUrl)
+
+ assertEquals("https://google.com?url=https://mozilla.org", this[1].url)
+ assertEquals("mozilla website", this[1].searchTerm)
+ assertEquals("https://google.com?q=mozilla+website", this[1].referrerUrl)
+ }
+
+ // Both tabs load.
+ store.dispatch(ContentAction.UpdateHistoryStateAction(parentTab.id, listOf(HistoryItem("Google - mozilla website", "https://google.com?q=mozilla+website")), 0)).joinBlocking()
+ store.dispatch(ContentAction.UpdateHistoryStateAction(tab.id, listOf(HistoryItem("", "https://google.com?url=mozilla+website")), currentIndex = 0)).joinBlocking()
+ with((service as TestingMetadataService).createdMetadata) {
+ assertEquals(2, this.count())
+ }
+
+ // Direct load occurs on child tab. Search terms should be cleared.
+ store.dispatch(EngineAction.LoadUrlAction(tab.id, "https://firefox.com")).joinBlocking()
+ store.dispatch(ContentAction.UpdateUrlAction(tab.id, "https://firefox.com")).joinBlocking()
+ store.dispatch(ContentAction.UpdateHistoryStateAction(tab.id, listOf(HistoryItem("", "https://google.com?url=mozilla+website"), HistoryItem("Firefox", "https://firefox.com")), 1)).joinBlocking()
+ with((service as TestingMetadataService).createdMetadata) {
+ assertEquals(3, this.count())
+ assertEquals("https://firefox.com", this[2].url)
+ assertNull(this[2].searchTerm)
+ assertNull(this[2].referrerUrl)
+ }
+
+ // Direct load occurs on parent tab. Search terms should be cleared.
+ store.dispatch(EngineAction.LoadUrlAction(parentTab.id, "https://firefox.com")).joinBlocking()
+ store.dispatch(ContentAction.UpdateSearchTermsAction(parentTab.id, "")).joinBlocking()
+ store.dispatch(ContentAction.UpdateUrlAction(parentTab.id, "https://firefox.com")).joinBlocking()
+ store.dispatch(ContentAction.UpdateHistoryStateAction(parentTab.id, listOf(HistoryItem("Google - mozilla website", "https://google.com?q=mozilla+website"), HistoryItem("Firefox", "https://firefox.com")), 1)).joinBlocking()
+ with((service as TestingMetadataService).createdMetadata) {
+ assertEquals(4, this.count())
+ assertEquals("https://firefox.com", this[3].url)
+ assertNull(this[3].searchTerm)
+ assertNull(this[3].referrerUrl)
+ }
+ }
+
+ @Test
+ fun `GIVEN tab with search terms WHEN subsequent optimized direct load occurs THEN search terms are not retained`() {
+ service = TestingMetadataService()
+ middleware = HistoryMetadataMiddleware(service)
+ store = BrowserStore(
+ middleware = listOf(middleware) + EngineMiddleware.create(engine = mockk()),
+ initialState = BrowserState(),
+ )
+ setupGoogleSearchEngine()
+
+ val parentTab = createTab("https://google.com?q=mozilla+website", searchTerms = "mozilla website")
+ val tab = createTab("https://google.com?url=https://mozilla.org", parent = parentTab)
+ store.dispatch(TabListAction.AddTabAction(parentTab, select = true)).joinBlocking()
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+
+ with((service as TestingMetadataService).createdMetadata) {
+ assertEquals(2, this.count())
+ assertEquals("https://google.com?q=mozilla+website", this[0].url)
+ assertEquals("mozilla website", this[0].searchTerm)
+ assertNull(this[0].referrerUrl)
+
+ assertEquals("https://google.com?url=https://mozilla.org", this[1].url)
+ assertEquals("mozilla website", this[1].searchTerm)
+ assertEquals("https://google.com?q=mozilla+website", this[1].referrerUrl)
+ }
+
+ // Both tabs load.
+ store.dispatch(ContentAction.UpdateHistoryStateAction(parentTab.id, listOf(HistoryItem("Google - mozilla website", "https://google.com?q=mozilla+website")), 0)).joinBlocking()
+ store.dispatch(ContentAction.UpdateHistoryStateAction(tab.id, listOf(HistoryItem("", "https://google.com?url=mozilla+website")), currentIndex = 0)).joinBlocking()
+ with((service as TestingMetadataService).createdMetadata) {
+ assertEquals(2, this.count())
+ }
+
+ // Direct load occurs on child tab. Search terms should be cleared.
+ store.dispatch(EngineAction.OptimizedLoadUrlTriggeredAction(tab.id, "https://firefox.com")).joinBlocking()
+ store.dispatch(ContentAction.UpdateUrlAction(tab.id, "https://firefox.com")).joinBlocking()
+ store.dispatch(ContentAction.UpdateHistoryStateAction(tab.id, listOf(HistoryItem("", "https://google.com?url=mozilla+website"), HistoryItem("Firefox", "https://firefox.com")), 1)).joinBlocking()
+ with((service as TestingMetadataService).createdMetadata) {
+ assertEquals(3, this.count())
+ assertEquals("https://firefox.com", this[2].url)
+ assertNull(this[2].searchTerm)
+ assertNull(this[2].referrerUrl)
+ }
+
+ // Direct load occurs on parent tab. Search terms should be cleared.
+ store.dispatch(EngineAction.OptimizedLoadUrlTriggeredAction(parentTab.id, "https://firefox.com")).joinBlocking()
+ store.dispatch(ContentAction.UpdateSearchTermsAction(parentTab.id, "")).joinBlocking()
+ store.dispatch(ContentAction.UpdateUrlAction(parentTab.id, "https://firefox.com")).joinBlocking()
+ store.dispatch(ContentAction.UpdateHistoryStateAction(parentTab.id, listOf(HistoryItem("Google - mozilla website", "https://google.com?q=mozilla+website"), HistoryItem("Firefox", "https://firefox.com")), 1)).joinBlocking()
+ with((service as TestingMetadataService).createdMetadata) {
+ assertEquals(4, this.count())
+ assertEquals("https://firefox.com", this[3].url)
+ assertNull(this[3].searchTerm)
+ assertNull(this[3].referrerUrl)
+ }
+ }
+
+ @Test
+ fun `GIVEN normal tab has parent WHEN url is the same THEN nothing happens`() {
+ val parentTab = createTab("https://mozilla.org")
+ val tab = createTab("https://mozilla.org", parent = parentTab)
+ store.dispatch(TabListAction.AddTabAction(parentTab)).joinBlocking()
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+
+ verify(exactly = 1) { service.createMetadata(parentTab, null, null) }
+ // Without our referrer url check, we would have recorded this metadata.
+ verify(exactly = 0) { service.createMetadata(tab, "mozilla website", "https://mozilla.org") }
+ store.dispatch(ContentAction.UpdateHistoryStateAction(tab.id, emptyList(), currentIndex = 0)).joinBlocking()
+ verify(exactly = 1) { service.createMetadata(any(), any(), any()) }
+ }
+
+ @Test
+ fun `GIVEN normal tab has no parent WHEN history metadata is recorded THEN search terms and referrer url are provided`() {
+ val tab = createTab("https://mozilla.org")
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ setupGoogleSearchEngine()
+
+ val historyState = listOf(
+ HistoryItem("firefox", "https://google.com?q=mozilla+website"),
+ HistoryItem("mozilla", "https://mozilla.org"),
+ )
+ store.dispatch(ContentAction.UpdateHistoryStateAction(tab.id, historyState, currentIndex = 1)).joinBlocking()
+
+ verify {
+ service.createMetadata(any(), eq("mozilla website"), eq("https://google.com?q=mozilla+website"))
+ }
+ }
+
+ @Test
+ fun `GIVEN normal tab has no parent WHEN history metadata is recorded without search terms THEN no referrer is provided`() {
+ val tab = createTab("https://mozilla.org")
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ setupGoogleSearchEngine()
+
+ val historyState = listOf(
+ HistoryItem("firefox", "https://mozilla.org"),
+ HistoryItem("mozilla", "https://firefox.com"),
+ )
+ store.dispatch(ContentAction.UpdateHistoryStateAction(tab.id, historyState, currentIndex = 1)).joinBlocking()
+
+ verify {
+ service.createMetadata(any(), null, null)
+ }
+ }
+
+ @Test
+ fun `GIVEN a normal tab with history state WHEN directly loaded THEN search terms and referrer not recorded`() {
+ val tab = createTab("https://mozilla.org")
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ setupGoogleSearchEngine()
+
+ val historyState = listOf(
+ HistoryItem("firefox", "https://google.com?q=mozilla+website"),
+ HistoryItem("mozilla", "https://mozilla.org"),
+ )
+ store.dispatch(EngineAction.LoadUrlAction(tab.id, tab.content.url)).joinBlocking()
+ store.dispatch(ContentAction.UpdateHistoryStateAction(tab.id, historyState, currentIndex = 1)).joinBlocking()
+
+ verify {
+ service.createMetadata(any(), null, null)
+ }
+
+ // Once direct load is "consumed", we're looking up the history stack again.
+ store.dispatch(ContentAction.UpdateHistoryStateAction(tab.id, historyState, currentIndex = 1)).joinBlocking()
+ verify {
+ service.createMetadata(any(), eq("mozilla website"), eq("https://google.com?q=mozilla+website"))
+ }
+ }
+
+ @Test
+ fun `GIVEN private tab WHEN loading completed THEN no meta data is recorded`() {
+ val tab = createTab("https://mozilla.org", private = true)
+
+ val expectedKey = HistoryMetadataKey(url = tab.content.url)
+ every { service.createMetadata(any(), any()) } returns expectedKey
+
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ verify { service wasNot Called }
+
+ store.dispatch(ContentAction.UpdateHistoryStateAction(tab.id, emptyList(), currentIndex = 0)).joinBlocking()
+ verify { service wasNot Called }
+ }
+
+ @Test
+ fun `GIVEN normal tab WHEN update url action event with a different url is received THEN meta data is updated`() {
+ val existingKey = HistoryMetadataKey(url = "https://mozilla.org")
+ val tab = createTab(url = existingKey.url, historyMetadata = existingKey)
+
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ verify { service wasNot Called }
+
+ store.dispatch(ContentAction.UpdateUrlAction(tab.id, "https://www.someother.url")).joinBlocking()
+ val capturedTab = slot<TabSessionState>()
+ verify { service.updateMetadata(existingKey, capture(capturedTab)) }
+
+ assertEquals(tab.id, capturedTab.captured.id)
+ }
+
+ @Test
+ fun `GIVEN normal tab WHEN update url action event with the same url is received THEN meta data is not updated`() {
+ val existingKey = HistoryMetadataKey(url = "https://mozilla.org")
+ val tab = createTab(url = existingKey.url, historyMetadata = existingKey)
+
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ verify { service wasNot Called }
+
+ store.dispatch(ContentAction.UpdateUrlAction(tab.id, existingKey.url)).joinBlocking()
+ verify { service wasNot Called }
+ }
+
+ @Test
+ fun `GIVEN tab without metadata WHEN user navigates and new page starts loading THEN nothing happens`() {
+ val tab = createTab(url = "https://mozilla.org")
+
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ verify(exactly = 1) { service.createMetadata(tab) }
+
+ store.dispatch(ContentAction.UpdateLoadingStateAction(tab.id, true)).joinBlocking()
+ verify(exactly = 1) { service.createMetadata(any()) }
+ verify(exactly = 0) { service.updateMetadata(any(), any()) }
+ }
+
+ @Test
+ fun `GIVEN tab is not selected WHEN user navigates and new page starts loading THEN nothing happens`() {
+ val existingKey = HistoryMetadataKey(url = "https://mozilla.org")
+ val tab = createTab(url = "https://mozilla.org", historyMetadata = existingKey)
+ val otherTab = createTab(url = "https://blog.mozilla.org")
+
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ store.dispatch(TabListAction.AddTabAction(otherTab, select = true)).joinBlocking()
+ val capturedTab = slot<TabSessionState>()
+ verify(exactly = 1) { service.updateMetadata(existingKey, capture(capturedTab)) }
+ assertEquals(tab.id, capturedTab.captured.id)
+
+ store.dispatch(ContentAction.UpdateLoadingStateAction(tab.id, true)).joinBlocking()
+ verify(exactly = 1) { service.updateMetadata(existingKey, capture(capturedTab)) }
+ }
+
+ @Test
+ fun `GIVEN normal media tab WHEN media state is updated THEN meta data is recorded`() {
+ val tab = createTab("https://media.mozilla.org")
+
+ val expectedKey = HistoryMetadataKey(url = tab.content.url)
+ every { service.createMetadata(any(), any()) } returns expectedKey
+
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ val capturedTabs = mutableListOf<TabSessionState>()
+ verify {
+ service.createMetadata(capture(capturedTabs), null, null)
+ }
+ assertEquals(tab.id, capturedTabs[0].id)
+ assertNull(capturedTabs[0].historyMetadata)
+ assertEquals(expectedKey, store.state.findTab(tab.id)?.historyMetadata)
+
+ store.dispatch(MediaSessionAction.UpdateMediaMetadataAction(tab.id, mockk())).joinBlocking()
+ verify {
+ service.createMetadata(capture(capturedTabs), null, null)
+ }
+
+ // Ugh, why are there three captured tabs when only two invocations of createMetadata happened?
+ assertEquals(tab.id, capturedTabs[2].id)
+ assertEquals(expectedKey, capturedTabs[2].historyMetadata)
+ assertEquals(expectedKey, store.state.findTab(tab.id)?.historyMetadata)
+ }
+
+ @Test
+ fun `GIVEN private media tab WHEN media state is updated THEN no meta data is recorded`() {
+ val tab = createTab("https://media.mozilla.org", private = true)
+
+ val expectedKey = HistoryMetadataKey(url = tab.content.url)
+ every { service.createMetadata(any(), any()) } returns expectedKey
+
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ verify { service wasNot Called }
+
+ store.dispatch(MediaSessionAction.UpdateMediaMetadataAction(tab.id, mockk())).joinBlocking()
+ verify { service wasNot Called }
+ }
+
+ @Test
+ fun `GIVEN normal tab is selected WHEN new tab will be added and selected THEN meta data is updated for currently selected tab`() {
+ val existingKey = HistoryMetadataKey(url = "https://mozilla.org")
+ val tab = createTab(url = "https://mozilla.org", historyMetadata = existingKey)
+ val otherTab = createTab(url = "https://blog.mozilla.org")
+ val yetAnotherTab = createTab(url = "https://media.mozilla.org")
+
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ store.dispatch(TabListAction.AddTabAction(otherTab)).joinBlocking()
+ verify(exactly = 1) { service.createMetadata(any()) }
+ verify(exactly = 0) { service.updateMetadata(any(), any()) }
+
+ store.dispatch(TabListAction.AddTabAction(yetAnotherTab, select = true)).joinBlocking()
+ val capturedTab = slot<TabSessionState>()
+ verify(exactly = 2) { service.createMetadata(any()) }
+ verify(exactly = 1) { service.updateMetadata(existingKey, capture(capturedTab)) }
+ assertEquals(tab.id, capturedTab.captured.id)
+ }
+
+ @Test
+ fun `GIVEN private tab is selected WHEN new tab will be added and selected THEN metadata not updated for private tab`() {
+ val existingKey = HistoryMetadataKey(url = "https://mozilla.org")
+ val tab = createTab(url = "https://mozilla.org", historyMetadata = existingKey, private = true)
+ val otherTab = createTab(url = "https://blog.mozilla.org")
+ val yetAnotherTab = createTab(url = "https://media.mozilla.org")
+
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ verify { service wasNot Called }
+ store.dispatch(TabListAction.AddTabAction(otherTab)).joinBlocking()
+ verify { service.createMetadata(otherTab) }
+
+ store.dispatch(TabListAction.AddTabAction(yetAnotherTab, select = true)).joinBlocking()
+ verify { service.createMetadata(yetAnotherTab) }
+ }
+
+ @Test
+ fun `GIVEN normal tab is selected WHEN new tab will be selected THEN meta data is updated for currently selected tab`() {
+ val existingKey = HistoryMetadataKey(url = "https://mozilla.org")
+ val tab = createTab(url = "https://mozilla.org", historyMetadata = existingKey)
+ val otherTab = createTab(url = "https://blog.mozilla.org")
+
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ verify { service wasNot Called }
+
+ store.dispatch(TabListAction.SelectTabAction(otherTab.id)).joinBlocking()
+ val capturedTab = slot<TabSessionState>()
+ verify(exactly = 1) { service.updateMetadata(existingKey, capture(capturedTab)) }
+ assertEquals(tab.id, capturedTab.captured.id)
+ }
+
+ @Test
+ fun `GIVEN private tab is selected WHEN new tab will be selected THEN nothing happens`() {
+ val existingKey = HistoryMetadataKey(url = "https://mozilla.org")
+ val tab = createTab(url = "https://mozilla.org", historyMetadata = existingKey, private = true)
+ val otherTab = createTab(url = "https://blog.mozilla.org")
+
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ verify { service wasNot Called }
+
+ store.dispatch(TabListAction.SelectTabAction(otherTab.id)).joinBlocking()
+ verify { service wasNot Called }
+ }
+
+ @Test
+ fun `WHEN normal selected tab is removed THEN meta data is updated`() {
+ val existingKey = HistoryMetadataKey(url = "https://mozilla.org")
+ val tab = createTab(url = "https://mozilla.org", historyMetadata = existingKey)
+
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ verify { service wasNot Called }
+
+ store.dispatch(TabListAction.RemoveTabAction(tab.id)).joinBlocking()
+ val capturedTab = slot<TabSessionState>()
+ verify(exactly = 1) { service.updateMetadata(existingKey, capture(capturedTab)) }
+ assertEquals(tab.id, capturedTab.captured.id)
+ }
+
+ @Test
+ fun `WHEN private selected tab is removed THEN nothing happens`() {
+ val existingKey = HistoryMetadataKey(url = "https://mozilla.org")
+ val tab = createTab(url = "https://mozilla.org", historyMetadata = existingKey, private = true)
+
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ verify { service wasNot Called }
+
+ store.dispatch(TabListAction.RemoveTabAction(tab.id)).joinBlocking()
+ verify { service wasNot Called }
+ }
+
+ @Test
+ fun `WHEN non-selected tab is removed THEN nothing happens`() {
+ val existingKey = HistoryMetadataKey(url = "https://mozilla.org")
+ val tab = createTab(url = "https://mozilla.org", historyMetadata = existingKey)
+ val otherTab = createTab(url = "https://blog.mozilla.org")
+
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ store.dispatch(TabListAction.AddTabAction(otherTab)).joinBlocking()
+ // 1 because 'tab' already has a metadata key set.
+ verify(exactly = 1) { service.createMetadata(any()) }
+
+ store.dispatch(TabListAction.RemoveTabAction(otherTab.id)).joinBlocking()
+ verify(exactly = 1) { service.createMetadata(any()) }
+ }
+
+ @Test
+ fun `GIVEN multiple tabs are removed WHEN selected normal tab should also be removed THEN meta data is updated`() {
+ val existingKey = HistoryMetadataKey(url = "https://mozilla.org")
+ val tab = createTab(url = "https://mozilla.org", historyMetadata = existingKey)
+ val otherTab = createTab(url = "https://blog.mozilla.org")
+ val yetAnotherTab = createTab(url = "https://media.mozilla.org")
+
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ store.dispatch(TabListAction.AddTabAction(otherTab)).joinBlocking()
+ store.dispatch(TabListAction.AddTabAction(yetAnotherTab)).joinBlocking()
+ // 'tab' already has an existing key, so metadata isn't created for it.
+ verify(exactly = 2) { service.createMetadata(any()) }
+
+ store.dispatch(TabListAction.RemoveTabsAction(listOf(tab.id, otherTab.id))).joinBlocking()
+ val capturedTab = slot<TabSessionState>()
+ verify(exactly = 1) { service.updateMetadata(existingKey, capture(capturedTab)) }
+ assertEquals(tab.id, capturedTab.captured.id)
+ }
+
+ @Test
+ fun `GIVEN multiple tabs are removed WHEN selected private tab should also be removed THEN nothing happens`() {
+ val existingKey = HistoryMetadataKey(url = "https://mozilla.org")
+ val tab = createTab(url = "https://mozilla.org", historyMetadata = existingKey, private = true)
+ val otherTab = createTab(url = "https://blog.mozilla.org")
+ val yetAnotherTab = createTab(url = "https://media.mozilla.org")
+
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ verify { service wasNot Called }
+ store.dispatch(TabListAction.AddTabAction(otherTab)).joinBlocking()
+ store.dispatch(TabListAction.AddTabAction(yetAnotherTab)).joinBlocking()
+ verify(exactly = 2) { service.createMetadata(any()) }
+
+ store.dispatch(TabListAction.RemoveTabsAction(listOf(tab.id, otherTab.id))).joinBlocking()
+ verify(exactly = 2) { service.createMetadata(any()) }
+ verify(exactly = 0) { service.updateMetadata(any(), any()) }
+ }
+
+ @Test
+ fun `GIVEN multiple tabs are removed WHEN selected tab should not be removed THEN nothing happens`() {
+ val existingKey = HistoryMetadataKey(url = "https://mozilla.org")
+ val tab = createTab(url = "https://mozilla.org", historyMetadata = existingKey)
+ val otherTab = createTab(url = "https://blog.mozilla.org")
+ val yetAnotherTab = createTab(url = "https://media.mozilla.org")
+
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ store.dispatch(TabListAction.AddTabAction(otherTab)).joinBlocking()
+ store.dispatch(TabListAction.AddTabAction(yetAnotherTab)).joinBlocking()
+ verify(exactly = 2) { service.createMetadata(any()) }
+ verify(exactly = 0) { service.updateMetadata(any(), any()) }
+
+ store.dispatch(TabListAction.RemoveTabsAction(listOf(otherTab.id, yetAnotherTab.id))).joinBlocking()
+ verify(exactly = 2) { service.createMetadata(any()) }
+ verify(exactly = 0) { service.updateMetadata(any(), any()) }
+ }
+
+ private fun setupGoogleSearchEngine() {
+ store.dispatch(
+ SearchAction.SetSearchEnginesAction(
+ regionSearchEngines = listOf(
+ SearchEngine(
+ id = "google",
+ name = "Google",
+ icon = mockk(),
+ type = SearchEngine.Type.BUNDLED,
+ resultUrls = listOf("https://google.com?q={searchTerms}"),
+ ),
+ ),
+ userSelectedSearchEngineId = null,
+ userSelectedSearchEngineName = null,
+ regionDefaultSearchEngineId = "google",
+ customSearchEngines = emptyList(),
+ hiddenSearchEngines = emptyList(),
+ disabledSearchEngineIds = emptyList(),
+ additionalAvailableSearchEngines = emptyList(),
+ additionalSearchEngines = emptyList(),
+ regionSearchEnginesOrder = listOf("google"),
+ ),
+ ).joinBlocking()
+ }
+
+ // Provides a more convenient way of capturing arguments for the functions we care about.
+ // I.e. capturing arguments in mockk was driving me mad and this is easy to understand and works.
+ class TestingMetadataService : HistoryMetadataService {
+ val createdMetadata = mutableListOf<HistoryMetadataKey>()
+
+ override fun createMetadata(
+ tab: TabSessionState,
+ searchTerms: String?,
+ referrerUrl: String?,
+ ): HistoryMetadataKey {
+ createdMetadata.add(HistoryMetadataKey(tab.content.url, searchTerms, referrerUrl))
+ return HistoryMetadataKey(tab.content.url, searchTerms, referrerUrl)
+ }
+
+ override fun updateMetadata(key: HistoryMetadataKey, tab: TabSessionState) {}
+ override fun cleanup(olderThan: Long) {}
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentvisits/HistoryMetadataServiceTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentvisits/HistoryMetadataServiceTest.kt
new file mode 100644
index 0000000000..54b469c716
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentvisits/HistoryMetadataServiceTest.kt
@@ -0,0 +1,132 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.recentvisits
+
+import io.mockk.coVerify
+import io.mockk.mockk
+import io.mockk.slot
+import kotlinx.coroutines.test.advanceUntilIdle
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.concept.storage.DocumentType
+import mozilla.components.concept.storage.HistoryMetadataKey
+import mozilla.components.concept.storage.HistoryMetadataObservation
+import mozilla.components.concept.storage.HistoryMetadataStorage
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.historymetadata.DefaultHistoryMetadataService
+import org.mozilla.fenix.historymetadata.HistoryMetadataService
+
+class HistoryMetadataServiceTest {
+
+ private lateinit var service: HistoryMetadataService
+ private lateinit var storage: HistoryMetadataStorage
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val scope = coroutinesTestRule.scope
+
+ @Before
+ fun setup() {
+ storage = mockk(relaxed = true)
+ service = DefaultHistoryMetadataService(storage, scope)
+ }
+
+ @Test
+ fun `GIVEN a regular page WHEN metadata is created THEN a regular document type observation is recorded`() = runTestOnMain {
+ val parent = createTab("https://mozilla.org")
+ val tab = createTab("https://blog.mozilla.org", parent = parent)
+ service.createMetadata(tab, searchTerms = "hello", referrerUrl = parent.content.url)
+ advanceUntilIdle()
+
+ val expectedKey = HistoryMetadataKey(url = tab.content.url, searchTerm = "hello", referrerUrl = parent.content.url)
+ val expectedObservation = HistoryMetadataObservation.DocumentTypeObservation(documentType = DocumentType.Regular)
+ coVerify { storage.noteHistoryMetadataObservation(expectedKey, expectedObservation) }
+ }
+
+ @Test
+ fun `GIVEN a media page WHEN metadata is created THEN a media document type observation is recorded`() = runTestOnMain {
+ val tab = createTab("https://media.mozilla.org", mediaSessionState = mockk())
+ service.createMetadata(tab)
+ advanceUntilIdle()
+
+ val expectedKey = HistoryMetadataKey(url = tab.content.url)
+ val expectedObservation = HistoryMetadataObservation.DocumentTypeObservation(documentType = DocumentType.Media)
+ coVerify { storage.noteHistoryMetadataObservation(expectedKey, expectedObservation) }
+ }
+
+ @Test
+ fun `GIVEN existing metadata WHEN metadata is created THEN correct document type observation is recorded`() = runTestOnMain {
+ val existingKey = HistoryMetadataKey(url = "https://media.mozilla.org", referrerUrl = "https://mozilla.org")
+ val tab = createTab("https://media.mozilla.org", historyMetadata = existingKey)
+ service.createMetadata(tab)
+ advanceUntilIdle()
+
+ var expectedKey = HistoryMetadataKey(url = tab.content.url, referrerUrl = existingKey.referrerUrl)
+ var expectedObservation = HistoryMetadataObservation.DocumentTypeObservation(documentType = DocumentType.Regular)
+ coVerify { storage.noteHistoryMetadataObservation(expectedKey, expectedObservation) }
+
+ val otherTab = createTab("https://blog.mozilla.org", historyMetadata = existingKey)
+ service.createMetadata(otherTab)
+ advanceUntilIdle()
+
+ expectedKey = HistoryMetadataKey(url = otherTab.content.url)
+ expectedObservation = HistoryMetadataObservation.DocumentTypeObservation(documentType = DocumentType.Regular)
+ coVerify { storage.noteHistoryMetadataObservation(expectedKey, expectedObservation) }
+ }
+
+ @Test
+ fun `WHEN metadata is updated THEN a view time observation is recorded`() = runTestOnMain {
+ val now = System.currentTimeMillis()
+ val key = HistoryMetadataKey(url = "https://blog.mozilla.org")
+ val tab = createTab(key.url, historyMetadata = key, lastAccess = now - 60 * 1000)
+ service.updateMetadata(key, tab)
+ advanceUntilIdle()
+
+ val observation = slot<HistoryMetadataObservation.ViewTimeObservation>()
+ coVerify { storage.noteHistoryMetadataObservation(key, capture(observation)) }
+ assertTrue(observation.captured.viewTime >= 60 * 1000)
+ }
+
+ @Test
+ fun `WHEN metadata is updated for a tab with no lastAccess THEN view time observation is not recorded`() = runTestOnMain {
+ val key = HistoryMetadataKey(url = "https://blog.mozilla.org")
+ val tab = createTab(key.url, historyMetadata = key, lastAccess = 0)
+ service.updateMetadata(key, tab)
+ advanceUntilIdle()
+
+ coVerify(exactly = 0) { storage.noteHistoryMetadataObservation(key, any()) }
+ }
+
+ @Test
+ fun `WHEN metadata is updated for a tab with unchanged lastAccess THEN view time observation is not recorded`() = runTestOnMain {
+ val now = System.currentTimeMillis()
+ val key = HistoryMetadataKey(url = "https://blog.mozilla.org")
+ val tab = createTab(key.url, historyMetadata = key, lastAccess = now - 60 * 1000)
+ service.updateMetadata(key, tab)
+ advanceUntilIdle()
+
+ val observation = slot<HistoryMetadataObservation.ViewTimeObservation>()
+ coVerify(exactly = 1) { storage.noteHistoryMetadataObservation(key, capture(observation)) }
+ assertTrue(observation.captured.viewTime >= 60 * 1000)
+
+ // Now, call update again with the same lastAccess value. Storage method won't be hit again.
+ service.updateMetadata(key, tab)
+ advanceUntilIdle()
+ coVerify(exactly = 1) { storage.noteHistoryMetadataObservation(key, any()) }
+ }
+
+ @Test
+ fun `WHEN cleanup is called THEN old metadata is deleted`() = runTestOnMain {
+ val timestamp = System.currentTimeMillis() - 7 * 24 * 60 * 60 * 1000
+ service.cleanup(timestamp)
+ advanceUntilIdle()
+
+ coVerify { storage.deleteHistoryMetadataOlderThan(timestamp) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentvisits/RecentVisitsFeatureTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentvisits/RecentVisitsFeatureTest.kt
new file mode 100644
index 0000000000..4b0933b7ae
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentvisits/RecentVisitsFeatureTest.kt
@@ -0,0 +1,800 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.recentvisits
+
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.spyk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import mozilla.components.browser.storage.sync.PlacesHistoryStorage
+import mozilla.components.concept.storage.DocumentType
+import mozilla.components.concept.storage.HistoryHighlight
+import mozilla.components.concept.storage.HistoryHighlightWeights
+import mozilla.components.concept.storage.HistoryMetadata
+import mozilla.components.concept.storage.HistoryMetadataKey
+import mozilla.components.concept.storage.HistoryMetadataStorage
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.middleware.CaptureActionsMiddleware
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppAction
+import org.mozilla.fenix.components.appstate.AppState
+import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
+import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryHighlight
+import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItemInternal.HistoryGroupInternal
+import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItemInternal.HistoryHighlightInternal
+import org.mozilla.fenix.utils.Settings
+import kotlin.random.Random
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class RecentVisitsFeatureTest {
+
+ private lateinit var historyHightlightsStorage: PlacesHistoryStorage
+ private lateinit var historyMetadataStorage: HistoryMetadataStorage
+
+ private val middleware = CaptureActionsMiddleware<AppState, AppAction>()
+ private val appStore = AppStore(middlewares = listOf(middleware))
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val testDispatcher = coroutinesTestRule.testDispatcher
+ private val scope = coroutinesTestRule.scope
+
+ @Before
+ fun setup() {
+ historyHightlightsStorage = mockk(relaxed = true)
+ historyMetadataStorage = mockk(relaxed = true)
+ Settings.SEARCH_GROUP_MINIMUM_SITES = 1
+ }
+
+ @Test
+ fun `GIVEN no recent visits WHEN feature starts THEN fetch history metadata and highlights then notify store`() =
+ runTestOnMain {
+ val historyEntry = HistoryMetadata(
+ key = HistoryMetadataKey("http://www.mozilla.com", "mozilla", null),
+ title = "mozilla",
+ createdAt = System.currentTimeMillis(),
+ updatedAt = System.currentTimeMillis(),
+ totalViewTime = 10,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null,
+ )
+ val recentHistoryGroup = RecentHistoryGroup(
+ title = "mozilla",
+ historyMetadata = listOf(historyEntry),
+ )
+ val highlightEntry = HistoryHighlight(1.0, 1, "https://firefox.com", "firefox", null)
+ val recentHistoryHighlight = RecentHistoryHighlight("firefox", "https://firefox.com")
+ coEvery { historyMetadataStorage.getHistoryMetadataSince(any()) }.coAnswers {
+ listOf(
+ historyEntry,
+ )
+ }
+ coEvery { historyHightlightsStorage.getHistoryHighlights(any(), any()) }.coAnswers {
+ listOf(highlightEntry)
+ }
+
+ startRecentVisitsFeature()
+
+ middleware.assertLastAction(AppAction.RecentHistoryChange::class) {
+ assertEquals(listOf(recentHistoryGroup, recentHistoryHighlight), it.recentHistory)
+ }
+ }
+
+ @Test
+ fun `WHEN asking for history highlights THEN use a specific query`() {
+ runTestOnMain {
+ val highlightWeights = slot<HistoryHighlightWeights>()
+ val highlightsAskedForNumber = slot<Int>()
+
+ startRecentVisitsFeature()
+
+ coVerify {
+ historyHightlightsStorage.getHistoryHighlights(
+ capture(highlightWeights),
+ capture(highlightsAskedForNumber),
+ )
+ }
+
+ assertEquals(MIN_VIEW_TIME_OF_HIGHLIGHT, highlightWeights.captured.viewTime, 0.0)
+ assertEquals(MIN_FREQUENCY_OF_HIGHLIGHT, highlightWeights.captured.frequency, 0.0)
+ assertEquals(MAX_RESULTS_TOTAL, highlightsAskedForNumber.captured)
+ }
+ }
+
+ @Test
+ fun `GIVEN groups containing history metadata items with the same url WHEN they are added to store THEN entries are deduped`() =
+ runTestOnMain {
+ val historyEntry1 = HistoryMetadata(
+ key = HistoryMetadataKey("http://www.mozilla.com", "mozilla", null),
+ title = "mozilla",
+ createdAt = System.currentTimeMillis(),
+ updatedAt = 1,
+ totalViewTime = 10,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null,
+ )
+
+ val historyEntry2 = HistoryMetadata(
+ key = HistoryMetadataKey("http://firefox.com", "mozilla", null),
+ title = "firefox",
+ createdAt = System.currentTimeMillis(),
+ updatedAt = 2,
+ totalViewTime = 20,
+ documentType = DocumentType.Regular,
+ previewImageUrl = "http://firefox.com/image1",
+ )
+
+ val historyEntry3 = HistoryMetadata(
+ key = HistoryMetadataKey("http://www.mozilla.com", "mozilla", null),
+ title = "mozilla",
+ createdAt = System.currentTimeMillis(),
+ updatedAt = 3,
+ totalViewTime = 30,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null,
+ )
+
+ val expectedHistoryGroup = RecentHistoryGroup(
+ title = "mozilla",
+ historyMetadata = listOf(
+ // Expected total view time to be summed up for deduped entries
+ historyEntry1.copy(
+ totalViewTime = historyEntry1.totalViewTime + historyEntry3.totalViewTime,
+ updatedAt = historyEntry3.updatedAt,
+ ),
+ historyEntry2,
+ ),
+ )
+
+ coEvery { historyMetadataStorage.getHistoryMetadataSince(any()) }.coAnswers {
+ listOf(
+ historyEntry1,
+ historyEntry2,
+ historyEntry3,
+ )
+ }
+
+ startRecentVisitsFeature()
+
+ middleware.assertLastAction(AppAction.RecentHistoryChange::class) {
+ assertEquals(listOf(expectedHistoryGroup), it.recentHistory)
+ }
+ }
+
+ @Test
+ fun `GIVEN different groups containing history metadata items with the same url WHEN they are added to store THEN entries are not deduped`() =
+ runTestOnMain {
+ val now = System.currentTimeMillis()
+ val historyEntry1 = HistoryMetadata(
+ key = HistoryMetadataKey("http://www.mozilla.com", "mozilla", null),
+ title = "mozilla",
+ createdAt = now,
+ updatedAt = now + 3,
+ totalViewTime = 10,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null,
+ )
+
+ val historyEntry2 = HistoryMetadata(
+ key = HistoryMetadataKey("http://firefox.com", "mozilla", null),
+ title = "firefox",
+ createdAt = now,
+ updatedAt = now + 2,
+ totalViewTime = 20,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null,
+ )
+
+ val historyEntry3 = HistoryMetadata(
+ key = HistoryMetadataKey("http://www.mozilla.com", "firefox", null),
+ title = "mozilla",
+ createdAt = now,
+ updatedAt = now + 1,
+ totalViewTime = 30,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null,
+ )
+
+ val expectedHistoryGroup1 = RecentHistoryGroup(
+ title = "mozilla",
+ historyMetadata = listOf(historyEntry1, historyEntry2),
+ )
+
+ val expectedHistoryGroup2 = RecentHistoryGroup(
+ title = "firefox",
+ historyMetadata = listOf(historyEntry3),
+ )
+
+ coEvery { historyMetadataStorage.getHistoryMetadataSince(any()) }.coAnswers {
+ listOf(
+ historyEntry1,
+ historyEntry2,
+ historyEntry3,
+ )
+ }
+
+ startRecentVisitsFeature()
+
+ middleware.assertLastAction(AppAction.RecentHistoryChange::class) {
+ assertEquals(listOf(expectedHistoryGroup1, expectedHistoryGroup2), it.recentHistory)
+ }
+ }
+
+ @Test
+ fun `GIVEN history groups WHEN they are added to store THEN they are sorted descending by last updated timestamp`() =
+ runTestOnMain {
+ val now = System.currentTimeMillis()
+ val historyEntry1 = HistoryMetadata(
+ key = HistoryMetadataKey("http://www.mozilla.com", "mozilla", null),
+ title = "mozilla",
+ createdAt = now,
+ updatedAt = now + 1,
+ totalViewTime = 10,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null,
+ )
+
+ val historyEntry2 = HistoryMetadata(
+ key = HistoryMetadataKey("http://firefox.com", "mozilla", null),
+ title = "firefox",
+ createdAt = now,
+ updatedAt = now + 2,
+ totalViewTime = 20,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null,
+ )
+
+ val historyEntry3 = HistoryMetadata(
+ key = HistoryMetadataKey("http://www.mozilla.com", "firefox", null),
+ title = "mozilla",
+ createdAt = now,
+ updatedAt = now + 3,
+ totalViewTime = 30,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null,
+ )
+
+ val expectedHistoryGroup1 = RecentHistoryGroup(
+ title = "mozilla",
+ historyMetadata = listOf(historyEntry1, historyEntry2),
+ )
+
+ val expectedHistoryGroup2 = RecentHistoryGroup(
+ title = "firefox",
+ historyMetadata = listOf(historyEntry3),
+ )
+
+ coEvery { historyMetadataStorage.getHistoryMetadataSince(any()) }.coAnswers {
+ listOf(
+ historyEntry1,
+ historyEntry2,
+ historyEntry3,
+ )
+ }
+
+ startRecentVisitsFeature()
+
+ middleware.assertLastAction(AppAction.RecentHistoryChange::class) {
+ assertEquals(listOf(expectedHistoryGroup2, expectedHistoryGroup1), it.recentHistory)
+ }
+ }
+
+ @Test
+ fun `GIVEN multiple groups exist but no highlights WHEN they are added to store THEN only MAX_RESULTS_TOTAL are sent`() =
+ runTestOnMain {
+ val visitsFromSearch = getSearchFromHistoryMetadataItems(10)
+ val expectedRecentHistoryGroups = visitsFromSearch
+ // Expect to only have the last accessed 9 groups.
+ .subList(1, 10)
+ .toIndividualRecentHistoryGroups()
+ coEvery { historyMetadataStorage.getHistoryMetadataSince(any()) }.coAnswers { visitsFromSearch }
+
+ startRecentVisitsFeature()
+
+ middleware.assertLastAction(AppAction.RecentHistoryChange::class) {
+ assertEquals(
+ // The 9 most recent groups.
+ expectedRecentHistoryGroups,
+ it.recentHistory,
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN multiple highlights exist but no history groups WHEN they are added to store THEN only MAX_RESULTS_TOTAL are sent`() =
+ runTestOnMain {
+ val highlights = getHistoryHighlightsItems(10)
+ val expectedRecentHighlights = highlights
+ // Expect to only have 9 highlights
+ .subList(0, 9)
+ .toRecentHistoryHighlights()
+ coEvery { historyHightlightsStorage.getHistoryHighlights(any(), any()) }.coAnswers { highlights }
+
+ startRecentVisitsFeature()
+
+ middleware.assertLastAction(AppAction.RecentHistoryChange::class) {
+ assertEquals(
+ expectedRecentHighlights,
+ it.recentHistory,
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN multiple history highlights and history groups WHEN they are added to store THEN only last accessed are added`() =
+ runTestOnMain {
+ val visitsFromSearch = getSearchFromHistoryMetadataItems(10)
+ val directVisits = getDirectVisitsHistoryMetadataItems(10)
+ val expectedRecentHistoryGroups = visitsFromSearch
+ // Expect only 4 groups. Take 5 here for using in the below zip() and be dropped after.
+ .subList(5, 10)
+ .toIndividualRecentHistoryGroups()
+ val expectedRecentHistoryHighlights = directVisits.reversed().toRecentHistoryHighlights()
+ val expectedItems = expectedRecentHistoryHighlights.zip(expectedRecentHistoryGroups).flatMap {
+ listOf(it.first, it.second)
+ }.take(9)
+ coEvery { historyMetadataStorage.getHistoryMetadataSince(any()) }.coAnswers { visitsFromSearch + directVisits }
+ coEvery { historyHightlightsStorage.getHistoryHighlights(any(), any()) }.coAnswers {
+ directVisits.toHistoryHighlights()
+ }
+
+ startRecentVisitsFeature()
+
+ middleware.assertLastAction(AppAction.RecentHistoryChange::class) {
+ assertEquals(expectedItems, it.recentHistory)
+ }
+ }
+
+ @Test
+ fun `GIVEN history highlights exist as history metadata WHEN they are added to store THEN don't add highlight dupes`() {
+ // To know if a highlight appears in a search group each visit's url should be checked.
+ val visitsFromSearch = getSearchFromHistoryMetadataItems(10)
+ val directDistinctVisits = getDirectVisitsHistoryMetadataItems(10).takeLast(2)
+ val directDupeVisits = visitsFromSearch.takeLast(2).map {
+ // Erase the search term for this to not be mapped to a search group.
+ // The url remains the same as the item from a group so it should be skipped.
+ it.copy(key = it.key.copy(searchTerm = null))
+ }
+ val expectedRecentHistoryGroups = visitsFromSearch
+ .subList(3, 10)
+ .toIndividualRecentHistoryGroups()
+ val expectedRecentHistoryHighlights = directDistinctVisits.reversed().toRecentHistoryHighlights()
+ val expectedItems = listOf(
+ expectedRecentHistoryHighlights.first(),
+ expectedRecentHistoryGroups.first(),
+ expectedRecentHistoryHighlights[1],
+ ) + expectedRecentHistoryGroups.subList(1, expectedRecentHistoryGroups.size)
+ coEvery { historyMetadataStorage.getHistoryMetadataSince(any()) }.coAnswers {
+ visitsFromSearch + directDistinctVisits + directDupeVisits
+ }
+ coEvery { historyHightlightsStorage.getHistoryHighlights(any(), any()) }.coAnswers {
+ directDistinctVisits.toHistoryHighlights() + directDupeVisits.toHistoryHighlights()
+ }
+
+ startRecentVisitsFeature()
+
+ middleware.assertLastAction(AppAction.RecentHistoryChange::class) {
+ assertEquals(expectedItems, it.recentHistory)
+ }
+ }
+
+ @Test
+ fun `GIVEN a list of history highlights and groups WHEN updateState is called THEN emit RecentHistoryChange`() {
+ val feature = spyk(RecentVisitsFeature(appStore, mockk(), mockk(), mockk(), mockk()))
+ val expected = List<RecentHistoryHighlight>(1) { mockk() }
+ every { feature.getCombinedHistory(any(), any()) } returns expected
+
+ feature.updateState(emptyList(), emptyList())
+ appStore.waitUntilIdle()
+
+ middleware.assertLastAction(AppAction.RecentHistoryChange::class) {
+ assertEquals(expected, it.recentHistory)
+ }
+ }
+
+ @Test
+ fun `GIVEN highlights visits exist in search groups WHEN getCombined is called THEN remove the highlights already in groups`() {
+ val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk())
+ val visitsFromSearch = getSearchFromHistoryMetadataItems(4)
+ val directVisits = getDirectVisitsHistoryMetadataItems(4)
+ val directDupeVisits = getSearchFromHistoryMetadataItems(2).map {
+ // Erase the search term for this to not be mapped to a search group.
+ // The url remains the same as the item from a group so it should be skipped.
+ it.copy(key = it.key.copy(searchTerm = null))
+ }
+ val expected = directVisits.reversed().toRecentHistoryHighlights()
+ .zip(visitsFromSearch.toIndividualRecentHistoryGroups())
+ .flatMap {
+ listOf(it.first, it.second)
+ }
+
+ val result = feature.getCombinedHistory(
+ (directVisits + directDupeVisits).toHistoryHighlightsInternal(),
+ visitsFromSearch.toHistoryGroupsInternal(),
+ )
+
+ assertEquals(expected, result)
+ }
+
+ @Test
+ fun `GIVEN fewer than needed highlights and search groups WHEN getCombined is called THEN the result is sorted by date`() {
+ val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk())
+ val visitsFromSearch = getSearchFromHistoryMetadataItems(4)
+ val directVisits = getDirectVisitsHistoryMetadataItems(4)
+ val expected = directVisits.reversed().toRecentHistoryHighlights()
+ .zip(visitsFromSearch.toIndividualRecentHistoryGroups())
+ .flatMap {
+ listOf(it.first, it.second)
+ }
+
+ val result = feature.getCombinedHistory(
+ directVisits.toHistoryHighlightsInternal(),
+ visitsFromSearch.toHistoryGroupsInternal(),
+ )
+
+ assertEquals(expected, result)
+ }
+
+ @Test
+ fun `GIVEN more highlights are newer than search groups WHEN getCombined is called THEN then return an even split then sorted by date`() {
+ val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk())
+ val visitsFromSearch = getSearchFromHistoryMetadataItems(5)
+ val directVisits = getDirectVisitsHistoryMetadataItems(14)
+ val expected = directVisits.takeLast(5).reversed().toRecentHistoryHighlights() +
+ visitsFromSearch.takeLast(4).toIndividualRecentHistoryGroups()
+
+ val result = feature.getCombinedHistory(
+ directVisits.toHistoryHighlightsInternal(),
+ visitsFromSearch.toHistoryGroupsInternal(),
+ )
+
+ assertEquals(expected, result)
+ }
+
+ @Test
+ fun `GIVEN more search groups are newer than highlights WHEN getCombined is called THEN then return an even split then sorted by date`() {
+ val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk())
+ val visitsFromSearch = getSearchFromHistoryMetadataItems(14)
+ val directVisits = getDirectVisitsHistoryMetadataItems(5)
+ val expected = visitsFromSearch.takeLast(4).toIndividualRecentHistoryGroups() +
+ directVisits.takeLast(5).reversed().toRecentHistoryHighlights()
+
+ val result = feature.getCombinedHistory(
+ directVisits.toHistoryHighlightsInternal(),
+ visitsFromSearch.toHistoryGroupsInternal(),
+ )
+
+ assertEquals(expected, result)
+ }
+
+ @Test
+ fun `GIVEN all highlights have metadata WHEN getHistoryHighlights is called THEN return a list of highlights with an inferred last access time`() {
+ val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk())
+ val visitsFromSearch = getSearchFromHistoryMetadataItems(10)
+ val directVisits = getDirectVisitsHistoryMetadataItems(10)
+
+ val result = feature.getHistoryHighlights(
+ directVisits.toHistoryHighlights(),
+ visitsFromSearch + directVisits,
+ )
+
+ assertEquals(
+ directVisits.toHistoryHighlightsInternal(),
+ result,
+ )
+ }
+
+ @Test
+ fun `GIVEN not all highlights have metadata WHEN getHistoryHighlights is called THEN set 0 for the highlights with not found last access time`() {
+ val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk())
+ val visitsFromSearch = getSearchFromHistoryMetadataItems(10)
+ val directVisits = getDirectVisitsHistoryMetadataItems(10)
+ val highlightsWithUnknownAccessTime = directVisits.toHistoryHighlightsInternal().take(5).map {
+ it.copy(lastAccessedTime = 0)
+ }
+ val highlightsWithInferredAccessTime = directVisits.toHistoryHighlightsInternal().takeLast(5)
+
+ val result = feature.getHistoryHighlights(
+ directVisits.toHistoryHighlights(),
+ visitsFromSearch + directVisits.takeLast(5),
+ )
+
+ assertEquals(
+ highlightsWithUnknownAccessTime + highlightsWithInferredAccessTime,
+ result,
+ )
+ }
+
+ @Test
+ fun `GIVEN multiple metadata records for the same highlight WHEN getHistoryHighlights is called THEN set the latest access time from multiple available`() {
+ val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk())
+ val visitsFromSearch = getSearchFromHistoryMetadataItems(10)
+ val directVisits = getDirectVisitsHistoryMetadataItems(10)
+ val newerDirectVisits = directVisits.mapIndexed { index, item ->
+ item.copy(updatedAt = item.updatedAt * ((index % 2) + 1))
+ }
+
+ val result = feature.getHistoryHighlights(
+ directVisits.toHistoryHighlights(),
+ visitsFromSearch + directVisits + newerDirectVisits,
+ )
+
+ assertEquals(
+ directVisits.mapIndexed { index, item ->
+ item.toHistoryHighlightInternal(item.updatedAt * ((index % 2) + 1))
+ },
+ result,
+ )
+ }
+
+ @Test
+ fun `GIVEN multiple metadata entries only for direct accessed pages WHEN getHistorySearchGroups is called THEN return an empty list`() {
+ val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk())
+ val directVisits = getDirectVisitsHistoryMetadataItems(10)
+
+ val result = feature.getHistorySearchGroups(directVisits)
+
+ assertTrue(result.isEmpty())
+ }
+
+ @Test
+ fun `GIVEN multiple metadata entries WHEN getHistorySearchGroups is called THEN group all entries by their search term`() {
+ val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk())
+ val visitsFromSearch = getSearchFromHistoryMetadataItems(10)
+ val directVisits = getDirectVisitsHistoryMetadataItems(10)
+
+ val result = feature.getHistorySearchGroups(visitsFromSearch + directVisits)
+
+ assertEquals(10, result.size)
+ assertEquals(visitsFromSearch.map { it.key.searchTerm }, result.map { it.groupName })
+ assertEquals(visitsFromSearch.map { listOf(it) }, result.map { it.groupItems })
+ }
+
+ @Test
+ fun `GIVEN multiple metadata entries for the same url WHEN getHistorySearchGroups is called THEN entries are deduped`() {
+ val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk())
+ val visitsFromSearch = getSearchFromHistoryMetadataItems(10)
+ val newerVisitsFromSearch = visitsFromSearch.map { it.copy(updatedAt = it.updatedAt * 2) }
+ val directVisits = getDirectVisitsHistoryMetadataItems(10)
+
+ val result = feature.getHistorySearchGroups(visitsFromSearch + directVisits + newerVisitsFromSearch)
+
+ assertEquals(10, result.size)
+ assertEquals(newerVisitsFromSearch.map { it.key.searchTerm }, result.map { it.groupName })
+ assertEquals(
+ newerVisitsFromSearch.map {
+ listOf(it.copy(totalViewTime = it.totalViewTime * 2))
+ },
+ result.map { it.groupItems },
+ )
+ }
+
+ @Test
+ fun `GIVEN highlights and search groups WHEN getSortedHistory is called THEN sort descending all items based on the last access time`() {
+ val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk())
+ val visitsFromSearch = getSearchFromHistoryMetadataItems(10)
+ val directVisits = getDirectVisitsHistoryMetadataItems(10)
+ val expected = directVisits.reversed().toRecentHistoryHighlights()
+ .zip(visitsFromSearch.toIndividualRecentHistoryGroups())
+ .flatMap {
+ listOf(it.first, it.second)
+ }
+
+ val result = feature.getSortedHistory(
+ directVisits.toHistoryHighlightsInternal(),
+ visitsFromSearch.toHistoryGroupsInternal(),
+ )
+
+ assertEquals(expected, result)
+ }
+
+ @Test
+ fun `GIVEN highlights don't have a valid title WHEN getSortedHistory is called THEN the url is set as title`() {
+ val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk())
+ val visitsFromSearch = getSearchFromHistoryMetadataItems(10)
+ val directVisits = getDirectVisitsHistoryMetadataItems(10).mapIndexed { index, item ->
+ when (index % 3) {
+ 0 -> item
+ 1 -> item.copy(title = null)
+ else -> item.copy(title = " ".repeat(Random.nextInt(3)))
+ }
+ }
+ val sortedByDateHighlights = directVisits.reversed()
+
+ val result = feature.getSortedHistory(
+ directVisits.toHistoryHighlightsInternal(),
+ visitsFromSearch.toHistoryGroupsInternal(),
+ ).filterIsInstance<RecentHistoryHighlight>()
+
+ assertEquals(10, result.size)
+ result.forEachIndexed { index, item ->
+ when (index % 3) {
+ 0 -> assertEquals(sortedByDateHighlights[index].title, item.title)
+ 1 -> assertEquals(sortedByDateHighlights[index].key.url, item.title)
+ 2 -> assertEquals(sortedByDateHighlights[index].key.url, item.title)
+ }
+ }
+ }
+
+ @Test
+ fun `GIVEN highlight visits also exist in search groups WHEN removeHighlightsAlreadyInGroups is called THEN filter out such highlights`() {
+ val visitsFromSearch = getSearchFromHistoryMetadataItems(10)
+ // To know if a highlight appears in a search group each visit's url should be checked.
+ // Ensure we have the identical urls with the ones from a search group and also some random others.
+ val directDupeVisits = visitsFromSearch.mapIndexed { index, item ->
+ when (index % 2) {
+ 0 -> item
+ else -> item.copy(key = item.key.copy(url = "https://mozilla.org"))
+ }
+ }
+ val highlights = directDupeVisits.toHistoryHighlightsInternal()
+
+ val result = highlights.removeHighlightsAlreadyInGroups(visitsFromSearch.toHistoryGroupsInternal())
+
+ assertEquals(5, result.size)
+ result.forEach { assertEquals("https://mozilla.org", it.historyHighlight.url) }
+ }
+
+ private fun startRecentVisitsFeature() {
+ val feature = RecentVisitsFeature(
+ appStore,
+ historyMetadataStorage,
+ lazy { historyHightlightsStorage },
+ scope,
+ testDispatcher,
+ )
+
+ assertEquals(emptyList<RecentHistoryGroup>(), appStore.state.recentHistory)
+
+ feature.start()
+
+ scope.advanceUntilIdle()
+ appStore.waitUntilIdle()
+
+ coVerify {
+ historyMetadataStorage.getHistoryMetadataSince(any())
+ }
+ }
+}
+
+/**
+ * Get a list of [HistoryMetadata] representing visits following a search with [count] different elements.
+ * The elements will have different `title`, `url`, `searchTerm` and an increasing `updatedAt` property
+ * based on their index in the returned list.
+ *
+ * This items can be mapped to search groups.
+ */
+private fun getSearchFromHistoryMetadataItems(count: Int): List<HistoryMetadata> {
+ return if (count > 0) {
+ val historyEntry1 = HistoryMetadata(
+ key = HistoryMetadataKey("https://searchurl1.test", "searchTerm1", null),
+ title = "test1",
+ createdAt = 0,
+ updatedAt = 1,
+ totalViewTime = 1,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null,
+ )
+ mutableListOf(historyEntry1) + (2..count).map {
+ historyEntry1.copy(
+ key = HistoryMetadataKey("https://searchurl$it.test", "searchTerm$it", null),
+ title = "test$it",
+ updatedAt = it.toLong(),
+ )
+ }
+ } else {
+ emptyList()
+ }
+}
+
+/**
+ * Get a list of [HistoryMetadata] representing directly accessed webpages with [count] different elements.
+ * The elements will have different `title`, `url` and an increasing `updatedAt` property
+ * based on their index in the returned list.
+ *
+ * This items cannot be mapped to search groups since they don't contain a `searchTerm`.
+ */
+private fun getDirectVisitsHistoryMetadataItems(count: Int): List<HistoryMetadata> {
+ return if (count > 0) {
+ val historyEntry1 = HistoryMetadata(
+ key = HistoryMetadataKey("https://url1.test", null),
+ title = "test1",
+ createdAt = 0,
+ updatedAt = 1,
+ totalViewTime = 1,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null,
+ )
+ mutableListOf(historyEntry1) + (2..count).map {
+ historyEntry1.copy(
+ key = HistoryMetadataKey("https://url$it.test", null),
+ title = "test$it",
+ updatedAt = it.toLong(),
+ )
+ }
+ } else {
+ emptyList()
+ }
+}
+
+/**
+ * Get a list of [HistoryHighlight] with [count] different elements.
+ * Each element will have unique value for all properties based on their index in the returned list.
+ */
+private fun getHistoryHighlightsItems(count: Int): List<HistoryHighlight> =
+ (1..count).map {
+ HistoryHighlight(
+ score = it.toDouble(),
+ placeId = it,
+ url = "https://url$it.test",
+ title = "test$it",
+ previewImageUrl = "https://previewImage$it.test",
+ )
+ }
+
+private fun HistoryMetadata.toHistoryHighlight(): HistoryHighlight = HistoryHighlight(
+ score = 3.0,
+ placeId = 2,
+ title = title,
+ url = key.url,
+ previewImageUrl = null,
+)
+
+private fun HistoryMetadata.toRecentHistoryGroup(): RecentHistoryGroup = RecentHistoryGroup(
+ title = key.searchTerm!!,
+ historyMetadata = listOf(this),
+)
+
+private fun List<HistoryMetadata>.toIndividualRecentHistoryGroups(): List<RecentHistoryGroup> =
+ map { it.toRecentHistoryGroup() }
+ .sortedByDescending { it.lastUpdated() }
+
+private fun HistoryMetadata.toRecentHistoryHighlight(): RecentHistoryHighlight =
+ RecentHistoryHighlight(
+ title = if (title.isNullOrBlank()) key.url else title!!,
+ url = key.url,
+ )
+
+private fun List<HistoryMetadata>.toRecentHistoryHighlights(): List<RecentHistoryHighlight> =
+ map { it.toRecentHistoryHighlight() }
+
+@JvmName("historyHighlightsToRecentHistoryHighlights") // avoid platform declaration clash with the above method
+private fun List<HistoryHighlight>.toRecentHistoryHighlights(): List<RecentHistoryHighlight> =
+ map {
+ RecentHistoryHighlight(
+ title = it.title!!,
+ url = it.url,
+ )
+ }
+
+private fun List<HistoryMetadata>.toHistoryHighlights() = map { it.toHistoryHighlight() }
+
+private fun HistoryMetadata.toHistoryHighlightInternal(lastAccessTime: Long) =
+ HistoryHighlightInternal(
+ historyHighlight = this.toHistoryHighlight(),
+ lastAccessedTime = lastAccessTime,
+ )
+
+private fun List<HistoryMetadata>.toHistoryHighlightsInternal() = mapIndexed { index, item ->
+ item.toHistoryHighlightInternal(index + 1L)
+}
+
+private fun HistoryMetadata.toHistoryGroupInternal() = HistoryGroupInternal(
+ groupName = key.searchTerm!!,
+ groupItems = listOf(this),
+)
+
+private fun List<HistoryMetadata>.toHistoryGroupsInternal() = map { it.toHistoryGroupInternal() }
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentvisits/controller/RecentVisitsControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentvisits/controller/RecentVisitsControllerTest.kt
new file mode 100644
index 0000000000..89e1d84a3f
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentvisits/controller/RecentVisitsControllerTest.kt
@@ -0,0 +1,183 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.recentvisits.controller
+
+import androidx.navigation.NavController
+import androidx.navigation.NavDirections
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import io.mockk.verifyOrder
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.advanceUntilIdle
+import mozilla.components.browser.state.action.HistoryMetadataAction
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.storage.DocumentType
+import mozilla.components.concept.storage.HistoryMetadata
+import mozilla.components.concept.storage.HistoryMetadataKey
+import mozilla.components.concept.storage.HistoryMetadataStorage
+import mozilla.components.feature.tabs.TabsUseCases.SelectOrAddUseCase
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.RecentSearches
+import org.mozilla.fenix.R
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppAction
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.home.HomeFragmentDirections
+import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
+import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryHighlight
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(FenixRobolectricTestRunner::class)
+class RecentVisitsControllerTest {
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val scope = coroutinesTestRule.scope
+
+ private val selectOrAddTabUseCase: SelectOrAddUseCase = mockk(relaxed = true)
+ private val navController = mockk<NavController>(relaxed = true)
+
+ private lateinit var storage: HistoryMetadataStorage
+ private lateinit var appStore: AppStore
+ private lateinit var store: BrowserStore
+
+ private lateinit var controller: DefaultRecentVisitsController
+
+ @Before
+ fun setup() {
+ every { navController.currentDestination } returns mockk {
+ every { id } returns R.id.homeFragment
+ }
+ storage = mockk(relaxed = true)
+ appStore = mockk(relaxed = true)
+ store = mockk(relaxed = true)
+
+ controller = spyk(
+ DefaultRecentVisitsController(
+ appStore = appStore,
+ store = store,
+ selectOrAddTabUseCase = selectOrAddTabUseCase,
+ navController = navController,
+ scope = scope,
+ storage = storage,
+ ),
+ )
+ }
+
+ @Test
+ fun handleHistoryShowAllClicked() = runTestOnMain {
+ controller.handleHistoryShowAllClicked()
+
+ verify {
+ navController.navigate(
+ HomeFragmentDirections.actionGlobalHistoryFragment(),
+ )
+ }
+ }
+
+ @Test
+ fun handleRecentHistoryGroupClicked() = runTestOnMain {
+ val historyEntry = HistoryMetadata(
+ key = HistoryMetadataKey("http://www.mozilla.com", "mozilla", null),
+ title = "mozilla",
+ createdAt = System.currentTimeMillis(),
+ updatedAt = System.currentTimeMillis(),
+ totalViewTime = 10,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null,
+ )
+ val historyGroup = RecentHistoryGroup(
+ title = "mozilla",
+ historyMetadata = listOf(historyEntry),
+ )
+
+ controller.handleRecentHistoryGroupClicked(historyGroup)
+
+ verify {
+ navController.navigate(
+ match<NavDirections> { it.actionId == R.id.action_global_history_metadata_group },
+ )
+ }
+ }
+
+ @Test
+ fun handleRemoveGroup() = runTestOnMain {
+ val historyMetadataKey = HistoryMetadataKey(
+ "http://www.mozilla.com",
+ "mozilla",
+ null,
+ )
+
+ val historyGroup = RecentHistoryGroup(
+ title = "mozilla",
+ historyMetadata = listOf(
+ HistoryMetadata(
+ key = historyMetadataKey,
+ title = "mozilla",
+ createdAt = System.currentTimeMillis(),
+ updatedAt = System.currentTimeMillis(),
+ totalViewTime = 10,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null,
+ ),
+ ),
+ )
+ assertNull(RecentSearches.groupDeleted.testGetValue())
+
+ controller.handleRemoveRecentHistoryGroup(historyGroup.title)
+
+ advanceUntilIdle()
+ verify {
+ store.dispatch(HistoryMetadataAction.DisbandSearchGroupAction(searchTerm = historyGroup.title))
+ appStore.dispatch(AppAction.DisbandSearchGroupAction(searchTerm = historyGroup.title))
+ }
+ assertNotNull(RecentSearches.groupDeleted.testGetValue())
+
+ coVerify {
+ storage.deleteHistoryMetadata(historyGroup.title)
+ }
+ }
+
+ @Test
+ fun handleRecentHistoryHighlightClicked() = runTestOnMain {
+ val historyHighlight = RecentHistoryHighlight("title", "url")
+
+ controller.handleRecentHistoryHighlightClicked(historyHighlight)
+
+ verifyOrder {
+ selectOrAddTabUseCase.invoke(historyHighlight.url)
+ navController.navigate(R.id.browserFragment)
+ }
+ }
+
+ @Test
+ fun handleRemoveRecentHistoryHighlight() = runTestOnMain {
+ val highlightUrl = "highlightUrl"
+ controller.handleRemoveRecentHistoryHighlight(highlightUrl)
+
+ verify {
+ appStore.dispatch(AppAction.RemoveRecentHistoryHighlight(highlightUrl))
+ scope.launch {
+ storage.deleteHistoryMetadataForUrl(highlightUrl)
+ }
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentvisits/interactor/RecentVisitsInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentvisits/interactor/RecentVisitsInteractorTest.kt
new file mode 100644
index 0000000000..e0be6f843c
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentvisits/interactor/RecentVisitsInteractorTest.kt
@@ -0,0 +1,132 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.recentvisits.interactor
+
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.concept.storage.DocumentType
+import mozilla.components.concept.storage.HistoryMetadata
+import mozilla.components.concept.storage.HistoryMetadataKey
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.home.pocket.PocketStoriesController
+import org.mozilla.fenix.home.privatebrowsing.controller.PrivateBrowsingController
+import org.mozilla.fenix.home.recentbookmarks.controller.RecentBookmarksController
+import org.mozilla.fenix.home.recentsyncedtabs.controller.RecentSyncedTabController
+import org.mozilla.fenix.home.recenttabs.controller.RecentTabController
+import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
+import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryHighlight
+import org.mozilla.fenix.home.recentvisits.controller.RecentVisitsController
+import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController
+import org.mozilla.fenix.home.sessioncontrol.SessionControlInteractor
+import org.mozilla.fenix.home.toolbar.ToolbarController
+import org.mozilla.fenix.search.toolbar.SearchSelectorController
+
+class RecentVisitsInteractorTest {
+ private val defaultSessionControlController: DefaultSessionControlController =
+ mockk(relaxed = true)
+ private val recentTabController: RecentTabController = mockk(relaxed = true)
+ private val recentSyncedTabController: RecentSyncedTabController = mockk(relaxed = true)
+ private val recentBookmarksController: RecentBookmarksController = mockk(relaxed = true)
+ private val recentVisitsController: RecentVisitsController = mockk(relaxed = true)
+ private val pocketStoriesController: PocketStoriesController = mockk(relaxed = true)
+ private val privateBrowsingController: PrivateBrowsingController = mockk(relaxed = true)
+ private val searchSelectorController: SearchSelectorController = mockk(relaxed = true)
+ private val toolbarController: ToolbarController = mockk(relaxed = true)
+
+ private lateinit var interactor: SessionControlInteractor
+
+ @Before
+ fun setup() {
+ interactor = SessionControlInteractor(
+ defaultSessionControlController,
+ recentTabController,
+ recentSyncedTabController,
+ recentBookmarksController,
+ recentVisitsController,
+ pocketStoriesController,
+ privateBrowsingController,
+ searchSelectorController,
+ toolbarController,
+ )
+ }
+
+ @Test
+ fun handleRecentHistoryGroupClicked() {
+ val historyGroup =
+ RecentHistoryGroup(
+ title = "mozilla",
+ historyMetadata = listOf(
+ HistoryMetadata(
+ key = HistoryMetadataKey("http://www.mozilla.com", null, null),
+ title = "mozilla",
+ createdAt = System.currentTimeMillis(),
+ updatedAt = System.currentTimeMillis(),
+ totalViewTime = 10,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null,
+ ),
+ ),
+ )
+
+ interactor.onRecentHistoryGroupClicked(historyGroup)
+ verify {
+ recentVisitsController.handleRecentHistoryGroupClicked(historyGroup)
+ }
+ }
+
+ @Test
+ fun handleHistoryShowAllClicked() {
+ interactor.onHistoryShowAllClicked()
+ verify { recentVisitsController.handleHistoryShowAllClicked() }
+ }
+
+ @Test
+ fun onRemoveRecentHistoryGroup() {
+ val historyMetadataKey = HistoryMetadataKey(
+ "http://www.mozilla.com",
+ "mozilla",
+ null,
+ )
+
+ val historyGroup =
+ RecentHistoryGroup(
+ title = "mozilla",
+ historyMetadata = listOf(
+ HistoryMetadata(
+ key = historyMetadataKey,
+ title = "mozilla",
+ createdAt = System.currentTimeMillis(),
+ updatedAt = System.currentTimeMillis(),
+ totalViewTime = 10,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null,
+ ),
+ ),
+ )
+
+ interactor.onRemoveRecentHistoryGroup(historyGroup.title)
+
+ verify {
+ recentVisitsController.handleRemoveRecentHistoryGroup(historyGroup.title)
+ }
+ }
+
+ @Test
+ fun onRecentHistoryHighlightClicked() {
+ val historyHighlight: RecentHistoryHighlight = mockk()
+
+ interactor.onRecentHistoryHighlightClicked(historyHighlight)
+
+ verify { recentVisitsController.handleRecentHistoryHighlightClicked(historyHighlight) }
+ }
+
+ @Test
+ fun onRemoveRecentHistoryHighlight() {
+ interactor.onRemoveRecentHistoryHighlight("url")
+
+ verify { recentVisitsController.handleRemoveRecentHistoryHighlight("url") }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentvisits/view/RecentBookmarksViewHolderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentvisits/view/RecentBookmarksViewHolderTest.kt
new file mode 100644
index 0000000000..4d67bc9c76
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/recentvisits/view/RecentBookmarksViewHolderTest.kt
@@ -0,0 +1,37 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.recentvisits.view
+
+// TODO: Needs testImplementation 'androidx.compose.ui:ui-test-junit4:1.0.0-beta04'
+@Suppress("ForbiddenComment")
+class RecentBookmarksViewHolderTest {
+ /*
+ @get:Rule
+ val composeTestRule = ComposeTestRule()
+
+ @Test
+ fun `WHEN a group is removed via long press menu THEN interactor is called`() {
+
+ val historyGroup = RecentVisitsItems(
+ title = "mozilla",
+ historyMetadata = listOf(
+ HistoryMetadata(
+ key = historyMetadataKey,
+ title = "mozilla",
+ createdAt = System.currentTimeMillis(),
+ updatedAt = System.currentTimeMillis(),
+ totalViewTime = 10,
+ documentType = DocumentType.Regular,
+ previewImageUrl = null
+ )
+ )
+ )
+
+ composeView.menuItems.performClick()
+ verify { interactor.onRemoveGroups(setOf(group)) }
+ }
+
+ */
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapterTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapterTest.kt
new file mode 100644
index 0000000000..bc0d10cd80
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapterTest.kt
@@ -0,0 +1,89 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.sessioncontrol
+
+import io.mockk.every
+import io.mockk.mockk
+import mozilla.components.feature.top.sites.TopSite
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.mozilla.fenix.home.sessioncontrol.AdapterItem.CollectionItem
+import org.mozilla.fenix.home.sessioncontrol.AdapterItem.TopSitePager
+import org.mozilla.fenix.home.sessioncontrol.AdapterItem.TopSitePagerPayload
+
+class SessionControlAdapterTest {
+
+ @Test
+ fun `WHEN getChangePayload called with wrong type THEN return null`() {
+ val newItem: AdapterItem = CollectionItem(mockk(), mockk(relaxed = true))
+
+ val result = TopSitePager(mockk()).getChangePayload(newItem)
+
+ assertNull(result)
+ }
+
+ @Test
+ fun `GIVEN topSitePager with 5 topSites WHEN getChangePayload with 10 items THEN return null`() {
+ val newItem = TopSitePager(mockk(relaxed = true))
+ val topSitePager = TopSitePager(mockk(relaxed = true))
+ every { topSitePager.topSites.size } returns 5
+ every { newItem.topSites.size } returns 10
+
+ val result = topSitePager.getChangePayload(newItem)
+
+ assertNull(result)
+ }
+
+ @Test
+ fun `GIVEN topSitePager with 10 topSites WHEN getChangePayload with 5 items THEN return null`() {
+ val newItem = TopSitePager(mockk(relaxed = true))
+ val topSitePager = TopSitePager(mockk(relaxed = true))
+ every { topSitePager.topSites.size } returns 10
+ every { newItem.topSites.size } returns 5
+
+ val result = topSitePager.getChangePayload(newItem)
+
+ assertNull(result)
+ }
+
+ @Test
+ fun `GIVEN topSitePager with 3 topSites WHEN getChangePayload with 5 items THEN return null`() {
+ val newItem = TopSitePager(mockk(relaxed = true))
+ val topSitePager = TopSitePager(mockk(relaxed = true))
+ every { topSitePager.topSites.size } returns 3
+ every { newItem.topSites.size } returns 5
+
+ val result = topSitePager.getChangePayload(newItem)
+
+ assertNull(result)
+ }
+
+ @Test
+ fun `GIVEN two topSites WHEN getChangePayload called with one changed item THEN return TopSitePagerPayload with changes`() {
+ val topSite0 = TopSite.Frecent(-1, "topSite0", "", 0)
+ val topSite1 = TopSite.Frecent(-1, "topSite1", "", 0)
+ val topSiteChanged = TopSite.Frecent(-1, "changed", "", 0)
+ val topSitePager = TopSitePager(listOf(topSite0, topSite1))
+ val newItem = TopSitePager(listOf(topSite0, topSiteChanged))
+
+ val result = topSitePager.getChangePayload(newItem)
+
+ assertEquals(TopSitePagerPayload(setOf(Pair(1, topSiteChanged))), result)
+ }
+
+ @Test
+ fun `GIVEN two topSites WHEN getChangePayload called with one removed THEN return TopSitePagerPayload with removed item`() {
+ val topSite0 = TopSite.Frecent(-1, "topSite0", "", 0)
+ val topSite1 = TopSite.Frecent(-1, "topSite1", "", 0)
+ val topSiteRemoved = TopSite.Frecent(-1, "REMOVED", "", 0)
+ val topSitePager = TopSitePager(listOf(topSite0, topSite1))
+ val newItem = TopSitePager(listOf(topSite0))
+
+ val result = topSitePager.getChangePayload(newItem)
+
+ assertEquals(TopSitePagerPayload(setOf(Pair(1, topSiteRemoved))), result)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/SessionControlViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/SessionControlViewTest.kt
new file mode 100644
index 0000000000..22239dfc86
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/SessionControlViewTest.kt
@@ -0,0 +1,291 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.sessioncontrol
+
+import io.mockk.every
+import io.mockk.mockk
+import mozilla.components.feature.tab.collections.TabCollection
+import mozilla.components.feature.top.sites.TopSite
+import mozilla.components.service.nimbus.messaging.Message
+import mozilla.components.service.pocket.PocketStory
+import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.mozilla.fenix.home.recentbookmarks.RecentBookmark
+import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
+import org.mozilla.fenix.utils.Settings
+
+class SessionControlViewTest {
+
+ @Test
+ fun `GIVEN recent Bookmarks WHEN normalModeAdapterItems is called THEN add a customize home button`() {
+ val settings: Settings = mockk()
+ val topSites = emptyList<TopSite>()
+ val collections = emptyList<TabCollection>()
+ val expandedCollections = emptySet<Long>()
+ val recentBookmarks = listOf(RecentBookmark())
+ val historyMetadata = emptyList<RecentHistoryGroup>()
+ val pocketStories = emptyList<PocketStory>()
+
+ every { settings.showTopSitesFeature } returns true
+ every { settings.showRecentTabsFeature } returns true
+ every { settings.showRecentBookmarksFeature } returns true
+ every { settings.historyMetadataUIFeature } returns true
+ every { settings.showPocketRecommendationsFeature } returns true
+
+ val results = normalModeAdapterItems(
+ settings,
+ topSites,
+ collections,
+ expandedCollections,
+ recentBookmarks,
+ false,
+ null,
+ false,
+ showRecentSyncedTab = false,
+ recentVisits = historyMetadata,
+ pocketStories = pocketStories,
+ )
+
+ assertTrue(results[0] is AdapterItem.TopPlaceholderItem)
+ assertTrue(results[1] is AdapterItem.RecentBookmarksHeader)
+ assertTrue(results[2] is AdapterItem.RecentBookmarks)
+ assertTrue(results[3] is AdapterItem.CustomizeHomeButton)
+ }
+
+ @Test
+ fun `GIVEN a nimbusMessageCard WHEN normalModeAdapterItems is called THEN add a NimbusMessageCard`() {
+ val settings: Settings = mockk()
+ val topSites = emptyList<TopSite>()
+ val collections = emptyList<TabCollection>()
+ val expandedCollections = emptySet<Long>()
+ val recentBookmarks = listOf(RecentBookmark())
+ val historyMetadata = emptyList<RecentHistoryGroup>()
+ val pocketStories = emptyList<PocketStory>()
+ val nimbusMessageCard: Message = mockk()
+
+ every { settings.showTopSitesFeature } returns true
+ every { settings.showRecentTabsFeature } returns true
+ every { settings.showRecentBookmarksFeature } returns true
+ every { settings.historyMetadataUIFeature } returns true
+ every { settings.showPocketRecommendationsFeature } returns true
+
+ val results = normalModeAdapterItems(
+ settings,
+ topSites,
+ collections,
+ expandedCollections,
+ recentBookmarks,
+ false,
+ nimbusMessageCard,
+ false,
+ showRecentSyncedTab = false,
+ historyMetadata,
+ pocketStories,
+ )
+
+ assertTrue(results.contains(AdapterItem.NimbusMessageCard(nimbusMessageCard)))
+ }
+
+ @Test
+ fun `GIVEN recent tabs WHEN normalModeAdapterItems is called THEN add a customize home button`() {
+ val settings: Settings = mockk()
+ val topSites = emptyList<TopSite>()
+ val collections = emptyList<TabCollection>()
+ val expandedCollections = emptySet<Long>()
+ val recentBookmarks = listOf<RecentBookmark>()
+ val historyMetadata = emptyList<RecentHistoryGroup>()
+ val pocketStories = emptyList<PocketStory>()
+
+ every { settings.showTopSitesFeature } returns true
+ every { settings.showRecentTabsFeature } returns true
+ every { settings.showRecentBookmarksFeature } returns true
+ every { settings.historyMetadataUIFeature } returns true
+ every { settings.showPocketRecommendationsFeature } returns true
+
+ val results = normalModeAdapterItems(
+ settings,
+ topSites,
+ collections,
+ expandedCollections,
+ recentBookmarks,
+ false,
+ null,
+ true,
+ showRecentSyncedTab = false,
+ historyMetadata,
+ pocketStories,
+ )
+
+ assertTrue(results[0] is AdapterItem.TopPlaceholderItem)
+ assertTrue(results[1] is AdapterItem.RecentTabsHeader)
+ assertTrue(results[2] is AdapterItem.RecentTabItem)
+ assertTrue(results[3] is AdapterItem.CustomizeHomeButton)
+ }
+
+ @Test
+ fun `GIVEN history metadata WHEN normalModeAdapterItems is called THEN add a customize home button`() {
+ val settings: Settings = mockk()
+ val topSites = emptyList<TopSite>()
+ val collections = emptyList<TabCollection>()
+ val expandedCollections = emptySet<Long>()
+ val recentBookmarks = listOf<RecentBookmark>()
+ val historyMetadata = listOf(RecentHistoryGroup("title", emptyList()))
+ val pocketStories = emptyList<PocketStory>()
+
+ every { settings.showTopSitesFeature } returns true
+ every { settings.showRecentTabsFeature } returns true
+ every { settings.showRecentBookmarksFeature } returns true
+ every { settings.historyMetadataUIFeature } returns true
+ every { settings.showPocketRecommendationsFeature } returns true
+
+ val results = normalModeAdapterItems(
+ settings,
+ topSites,
+ collections,
+ expandedCollections,
+ recentBookmarks,
+ false,
+ null,
+ false,
+ showRecentSyncedTab = false,
+ historyMetadata,
+ pocketStories,
+ )
+
+ assertTrue(results[0] is AdapterItem.TopPlaceholderItem)
+ assertTrue(results[1] is AdapterItem.RecentVisitsHeader)
+ assertTrue(results[2] is AdapterItem.RecentVisitsItems)
+ assertTrue(results[3] is AdapterItem.CustomizeHomeButton)
+ }
+
+ @Test
+ fun `GIVEN pocket articles WHEN normalModeAdapterItems is called THEN add a customize home button`() {
+ val settings: Settings = mockk()
+ val topSites = emptyList<TopSite>()
+ val collections = emptyList<TabCollection>()
+ val expandedCollections = emptySet<Long>()
+ val recentBookmarks = listOf<RecentBookmark>()
+ val historyMetadata = emptyList<RecentHistoryGroup>()
+ val pocketStories = listOf(PocketRecommendedStory("", "", "", "", "", 1, 1))
+
+ every { settings.showTopSitesFeature } returns true
+ every { settings.showRecentTabsFeature } returns true
+ every { settings.showRecentBookmarksFeature } returns true
+ every { settings.historyMetadataUIFeature } returns true
+ every { settings.showPocketRecommendationsFeature } returns true
+
+ val results = normalModeAdapterItems(
+ settings,
+ topSites,
+ collections,
+ expandedCollections,
+ recentBookmarks,
+ false,
+ null,
+ false,
+ showRecentSyncedTab = false,
+ historyMetadata,
+ pocketStories,
+ true,
+ )
+
+ assertTrue(results[0] is AdapterItem.TopPlaceholderItem)
+ assertTrue(results[1] is AdapterItem.PocketStoriesItem)
+ assertTrue(results[2] is AdapterItem.PocketCategoriesItem)
+ assertTrue(results[3] is AdapterItem.PocketRecommendationsFooterItem)
+ assertTrue(results[4] is AdapterItem.CustomizeHomeButton)
+
+ // When the first frame has not yet drawn don't add pocket.
+ val results2 = normalModeAdapterItems(
+ settings,
+ topSites,
+ collections,
+ expandedCollections,
+ recentBookmarks,
+ false,
+ null,
+ false,
+ showRecentSyncedTab = false,
+ historyMetadata,
+ pocketStories,
+ false,
+ )
+
+ assertTrue(results2[0] is AdapterItem.TopPlaceholderItem)
+ assertTrue(results2[1] is AdapterItem.BottomSpacer)
+ }
+
+ @Test
+ fun `GIVEN none recentBookmarks,recentTabs, historyMetadata or pocketArticles WHEN normalModeAdapterItems is called THEN the customize home button is not added`() {
+ val settings: Settings = mockk()
+ val topSites = emptyList<TopSite>()
+ val collections = emptyList<TabCollection>()
+ val expandedCollections = emptySet<Long>()
+ val recentBookmarks = listOf<RecentBookmark>()
+ val historyMetadata = emptyList<RecentHistoryGroup>()
+ val pocketStories = emptyList<PocketStory>()
+
+ every { settings.showTopSitesFeature } returns true
+ every { settings.showRecentTabsFeature } returns true
+ every { settings.showRecentBookmarksFeature } returns true
+ every { settings.historyMetadataUIFeature } returns true
+ every { settings.showPocketRecommendationsFeature } returns true
+
+ val results = normalModeAdapterItems(
+ settings,
+ topSites,
+ collections,
+ expandedCollections,
+ recentBookmarks,
+ false,
+ null,
+ false,
+ showRecentSyncedTab = false,
+ historyMetadata,
+ pocketStories,
+ )
+ assertEquals(results.size, 2)
+ assertTrue(results[0] is AdapterItem.TopPlaceholderItem)
+ }
+
+ @Test
+ fun `GIVEN all items THEN top placeholder item is always the first item`() {
+ val settings: Settings = mockk()
+ val collection = mockk<TabCollection> {
+ every { id } returns 123L
+ }
+ val topSites = listOf<TopSite>(mockk())
+ val collections = listOf(collection)
+ val expandedCollections = emptySet<Long>()
+ val recentBookmarks = listOf<RecentBookmark>(mockk())
+ val historyMetadata = listOf<RecentHistoryGroup>(mockk())
+ val pocketStories = listOf<PocketStory>(mockk())
+
+ every { settings.showTopSitesFeature } returns true
+ every { settings.showRecentTabsFeature } returns true
+ every { settings.showRecentBookmarksFeature } returns true
+ every { settings.historyMetadataUIFeature } returns true
+ every { settings.showPocketRecommendationsFeature } returns true
+ every { settings.enableComposeTopSites } returns false
+
+ val results = normalModeAdapterItems(
+ settings,
+ topSites,
+ collections,
+ expandedCollections,
+ recentBookmarks,
+ false,
+ null,
+ true,
+ showRecentSyncedTab = true,
+ historyMetadata,
+ pocketStories,
+ )
+
+ assertTrue(results[0] is AdapterItem.TopPlaceholderItem)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/viewholders/NoCollectionsMessageViewHolderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/viewholders/NoCollectionsMessageViewHolderTest.kt
new file mode 100644
index 0000000000..dea4f6059f
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/viewholders/NoCollectionsMessageViewHolderTest.kt
@@ -0,0 +1,89 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.sessioncontrol.viewholders
+
+import android.view.LayoutInflater
+import androidx.core.view.isVisible
+import androidx.lifecycle.LifecycleOwner
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.databinding.NoCollectionsMessageBinding
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.home.sessioncontrol.CollectionInteractor
+
+@RunWith(FenixRobolectricTestRunner::class)
+class NoCollectionsMessageViewHolderTest {
+
+ private lateinit var binding: NoCollectionsMessageBinding
+ private val store: BrowserStore = BrowserStore(
+ initialState = BrowserState(
+ listOf(
+ createTab("https://www.mozilla.org", id = "reader-inactive-tab"),
+ ),
+ ),
+ )
+ private lateinit var lifecycleOwner: LifecycleOwner
+ private lateinit var interactor: CollectionInteractor
+ private lateinit var appStore: AppStore
+
+ @Before
+ fun setup() {
+ binding = NoCollectionsMessageBinding.inflate(LayoutInflater.from(testContext))
+
+ appStore = AppStore()
+
+ lifecycleOwner = mockk(relaxed = true)
+ interactor = mockk(relaxed = true)
+ }
+
+ @Test
+ fun `hide add to collection button when there are no tabs open`() {
+ val noTabsStore = BrowserStore()
+ NoCollectionsMessageViewHolder(
+ binding.root,
+ lifecycleOwner,
+ noTabsStore,
+ appStore,
+ interactor,
+ )
+
+ assertFalse(binding.addTabsToCollectionsButton.isVisible)
+ }
+
+ @Test
+ fun `show add to collection button when there are tabs`() {
+ NoCollectionsMessageViewHolder(binding.root, lifecycleOwner, store, appStore, interactor)
+
+ assertTrue(binding.addTabsToCollectionsButton.isVisible)
+ }
+
+ @Test
+ fun `call interactor on click`() {
+ NoCollectionsMessageViewHolder(binding.root, lifecycleOwner, store, appStore, interactor)
+
+ binding.addTabsToCollectionsButton.performClick()
+ verify { interactor.onAddTabsToCollectionTapped() }
+ }
+
+ @Test
+ fun `hide view and change setting on remove placeholder click`() {
+ NoCollectionsMessageViewHolder(binding.root, lifecycleOwner, store, appStore, interactor)
+
+ binding.removeCollectionPlaceholder.performClick()
+ verify {
+ interactor.onRemoveCollectionsPlaceholder()
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/toolbar/DefaultToolbarControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/toolbar/DefaultToolbarControllerTest.kt
new file mode 100644
index 0000000000..40b35e652a
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/toolbar/DefaultToolbarControllerTest.kt
@@ -0,0 +1,190 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.toolbar
+
+import androidx.navigation.NavController
+import androidx.navigation.NavDirections
+import androidx.navigation.NavOptions
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.browser.state.search.SearchEngine
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.SearchState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.BrowserDirection
+import org.mozilla.fenix.GleanMetrics.Events
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.R
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.search.ExtraAction
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class) // For gleanTestRule
+class DefaultToolbarControllerTest {
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ private val activity: HomeActivity = mockk(relaxed = true)
+ private val navController: NavController = mockk(relaxed = true)
+ private val settings: Settings = mockk(relaxed = true)
+
+ private val searchEngine = SearchEngine(
+ id = "test",
+ name = "Test Engine",
+ icon = mockk(relaxed = true),
+ type = SearchEngine.Type.BUNDLED,
+ resultUrls = listOf("https://example.org/?q={searchTerms}"),
+ )
+
+ private lateinit var store: BrowserStore
+
+ @Before
+ fun setup() {
+ store = BrowserStore(
+ initialState = BrowserState(
+ search = SearchState(
+ regionSearchEngines = listOf(searchEngine),
+ ),
+ ),
+ )
+
+ every { navController.currentDestination } returns mockk {
+ every { id } returns R.id.homeFragment
+ }
+ every { activity.settings() } returns settings
+ }
+
+ @Test
+ fun `WHEN Paste & Go toolbar menu is clicked THEN open the browser with the clipboard text as the search term`() {
+ assertNull(Events.enteredUrl.testGetValue())
+ assertNull(Events.performedSearch.testGetValue())
+
+ var clipboardText = "text"
+ createController().handlePasteAndGo(clipboardText)
+
+ verify {
+ activity.openToBrowserAndLoad(
+ searchTermOrURL = clipboardText,
+ newTab = true,
+ from = BrowserDirection.FromHome,
+ engine = searchEngine,
+ )
+ }
+
+ assertNotNull(Events.performedSearch.testGetValue())
+
+ clipboardText = "https://mozilla.org"
+ createController().handlePasteAndGo(clipboardText)
+
+ verify {
+ activity.openToBrowserAndLoad(
+ searchTermOrURL = clipboardText,
+ newTab = true,
+ from = BrowserDirection.FromHome,
+ engine = searchEngine,
+ )
+ }
+
+ assertNotNull(Events.enteredUrl.testGetValue())
+ }
+
+ @Test
+ fun `WHEN Paste toolbar menu is clicked THEN navigate to the search dialog`() {
+ createController().handlePaste("text")
+
+ verify {
+ navController.navigate(
+ match<NavDirections> { it.actionId == R.id.action_global_search_dialog },
+ null,
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN the toolbar is tapped THEN navigate to the search dialog`() {
+ assertNull(Events.searchBarTapped.testGetValue())
+
+ createController().handleNavigateSearch()
+
+ assertNotNull(Events.searchBarTapped.testGetValue())
+
+ val recordedEvents = Events.searchBarTapped.testGetValue()!!
+ assertEquals(1, recordedEvents.size)
+ assertEquals("HOME", recordedEvents.single().extra?.getValue("source"))
+
+ verify {
+ navController.navigate(
+ match<NavDirections> { it.actionId == R.id.action_global_search_dialog },
+ any<NavOptions>(),
+ )
+ }
+ }
+
+ @Test
+ @Suppress("Deprecation")
+ fun `WHEN the toolbar QR button is tapped THEN navigate to the search dialog with QR reader activated`() {
+ assertNull(Events.searchBarTapped.testGetValue())
+
+ createController().handleNavigateSearch(ExtraAction.QR_READER)
+
+ assertNotNull(Events.searchBarTapped.testGetValue())
+
+ val recordedEvents = Events.searchBarTapped.testGetValue()!!
+ assertEquals(1, recordedEvents.size)
+ assertEquals("HOME_QR", recordedEvents.single().extra?.getValue("source"))
+
+ verify {
+ navController.navigate(
+ match<NavDirections> {
+ it.actionId == R.id.action_global_search_dialog &&
+ it.arguments.get("extra_action") as ExtraAction == ExtraAction.QR_READER
+ },
+ any<NavOptions>(),
+ )
+ }
+ }
+
+ @Test
+ @Suppress("Deprecation")
+ fun `WHEN the toolbar VOICE button is tapped THEN navigate to the search dialog with voice search activated`() {
+ assertNull(Events.searchBarTapped.testGetValue())
+
+ createController().handleNavigateSearch(ExtraAction.VOICE_SEARCH)
+
+ assertNotNull(Events.searchBarTapped.testGetValue())
+
+ val recordedEvents = Events.searchBarTapped.testGetValue()!!
+ assertEquals(1, recordedEvents.size)
+ assertEquals("HOME_VOICE", recordedEvents.single().extra?.getValue("source"))
+
+ verify {
+ navController.navigate(
+ match<NavDirections> {
+ it.actionId == R.id.action_global_search_dialog &&
+ it.arguments.get("extra_action") as ExtraAction == ExtraAction.VOICE_SEARCH
+ },
+ any<NavOptions>(),
+ )
+ }
+ }
+
+ private fun createController() = DefaultToolbarController(
+ activity = activity,
+ store = store,
+ navController = navController,
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/topsites/TopSiteItemViewHolderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/topsites/TopSiteItemViewHolderTest.kt
new file mode 100644
index 0000000000..9cf7492bee
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/topsites/TopSiteItemViewHolderTest.kt
@@ -0,0 +1,155 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.topsites
+
+import android.view.LayoutInflater
+import androidx.lifecycle.LifecycleOwner
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.browser.icons.BrowserIcons
+import mozilla.components.feature.top.sites.TopSite
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.telemetry.glean.testing.GleanTestRule
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.Pings
+import org.mozilla.fenix.GleanMetrics.TopSites
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.databinding.TopSiteItemBinding
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.home.sessioncontrol.TopSiteInteractor
+
+@RunWith(FenixRobolectricTestRunner::class)
+class TopSiteItemViewHolderTest {
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ private lateinit var binding: TopSiteItemBinding
+ private lateinit var interactor: TopSiteInteractor
+ private lateinit var lifecycleOwner: LifecycleOwner
+ private lateinit var appStore: AppStore
+
+ private val pocket = TopSite.Default(
+ id = 1L,
+ title = "Pocket",
+ url = "https://getpocket.com",
+ createdAt = 0,
+ )
+
+ @Before
+ fun setup() {
+ binding = TopSiteItemBinding.inflate(LayoutInflater.from(testContext))
+ interactor = mockk(relaxed = true)
+ lifecycleOwner = mockk(relaxed = true)
+ appStore = mockk(relaxed = true)
+
+ every { testContext.components.core.icons } returns BrowserIcons(testContext, mockk(relaxed = true))
+ }
+
+ @Test
+ fun `calls interactor on click`() {
+ TopSiteItemViewHolder(binding.root, appStore, lifecycleOwner, interactor).bind(pocket, position = 0)
+
+ binding.root.performClick()
+ verify { interactor.onSelectTopSite(pocket, position = 0) }
+ }
+
+ @Test
+ fun `GIVEN a default top site WHEN bind is called THEN the title has a pin indicator`() {
+ val defaultTopSite = TopSite.Default(
+ id = 1L,
+ title = "Pocket",
+ url = "https://getpocket.com",
+ createdAt = 0,
+ )
+
+ TopSiteItemViewHolder(binding.root, appStore, lifecycleOwner, interactor).bind(defaultTopSite, position = 0)
+ val pinIndicator = binding.topSiteTitle.compoundDrawables[0]
+
+ assertNotNull(pinIndicator)
+ }
+
+ @Test
+ fun `GIVEN a pinned top site WHEN bind is called THEN the title has a pin indicator`() {
+ val pinnedTopSite = TopSite.Pinned(
+ id = 1L,
+ title = "Mozilla",
+ url = "https://www.mozilla.org",
+ createdAt = 0,
+ )
+
+ TopSiteItemViewHolder(binding.root, appStore, lifecycleOwner, interactor).bind(pinnedTopSite, position = 0)
+ val pinIndicator = binding.topSiteTitle.compoundDrawables[0]
+
+ assertNotNull(pinIndicator)
+ }
+
+ @Test
+ fun `GIVEN a frecent top site WHEN bind is called THEN the title does not have a pin indicator`() {
+ val frecentTopSite = TopSite.Frecent(
+ id = 1L,
+ title = "Mozilla",
+ url = "https://www.mozilla.org",
+ createdAt = 0,
+ )
+
+ TopSiteItemViewHolder(binding.root, appStore, lifecycleOwner, interactor).bind(frecentTopSite, position = 0)
+ val pinIndicator = binding.topSiteTitle.compoundDrawables[0]
+
+ assertNull(pinIndicator)
+ }
+
+ @Test
+ fun `GIVEN a provided top site and position WHEN the provided top site is shown THEN submit a top site impression ping`() {
+ val topSite = TopSite.Provided(
+ id = 3,
+ title = "Mozilla",
+ url = "https://mozilla.com",
+ clickUrl = "https://mozilla.com/click",
+ imageUrl = "https://test.com/image2.jpg",
+ impressionUrl = "https://example.com",
+ createdAt = 3,
+ )
+ val position = 0
+ assertNull(TopSites.contileImpression.testGetValue())
+
+ var topSiteImpressionSubmitted = false
+ Pings.topsitesImpression.testBeforeNextSubmit {
+ assertNotNull(TopSites.contileTileId.testGetValue())
+ assertEquals(3L, TopSites.contileTileId.testGetValue())
+
+ assertNotNull(TopSites.contileAdvertiser.testGetValue())
+ assertEquals("mozilla", TopSites.contileAdvertiser.testGetValue())
+
+ assertNotNull(TopSites.contileReportingUrl.testGetValue())
+ assertEquals(topSite.impressionUrl, TopSites.contileReportingUrl.testGetValue())
+
+ topSiteImpressionSubmitted = true
+ }
+
+ TopSiteItemViewHolder(binding.root, appStore, lifecycleOwner, interactor).submitTopSitesImpressionPing(topSite, position)
+
+ assertNotNull(TopSites.contileImpression.testGetValue())
+
+ val event = TopSites.contileImpression.testGetValue()!!
+
+ assertEquals(1, event.size)
+ assertEquals("top_sites", event[0].category)
+ assertEquals("contile_impression", event[0].name)
+ assertEquals("1", event[0].extra!!["position"])
+ assertEquals("newtab", event[0].extra!!["source"])
+
+ assertTrue(topSiteImpressionSubmitted)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/topsites/TopSiteViewHolderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/topsites/TopSiteViewHolderTest.kt
new file mode 100644
index 0000000000..c7bb4d13c4
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/topsites/TopSiteViewHolderTest.kt
@@ -0,0 +1,52 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.topsites
+
+import android.view.LayoutInflater
+import androidx.lifecycle.LifecycleOwner
+import io.mockk.mockk
+import mozilla.components.feature.top.sites.TopSite
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.databinding.ComponentTopSitesBinding
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.home.sessioncontrol.TopSiteInteractor
+
+@RunWith(FenixRobolectricTestRunner::class)
+class TopSiteViewHolderTest {
+
+ private lateinit var binding: ComponentTopSitesBinding
+ private lateinit var lifecycleOwner: LifecycleOwner
+ private lateinit var interactor: TopSiteInteractor
+ private lateinit var appStore: AppStore
+
+ @Before
+ fun setup() {
+ binding = ComponentTopSitesBinding.inflate(LayoutInflater.from(testContext))
+ interactor = mockk(relaxed = true)
+ lifecycleOwner = mockk(relaxed = true)
+ appStore = mockk(relaxed = true)
+ }
+
+ @Test
+ fun `binds list of top sites`() {
+ TopSiteViewHolder(binding.root, appStore, lifecycleOwner, interactor).bind(
+ listOf(
+ TopSite.Default(
+ id = 1L,
+ title = "Pocket",
+ url = "https://getpocket.com",
+ createdAt = 0,
+ ),
+ ),
+ )
+
+ assertEquals(1, binding.topSitesList.adapter!!.itemCount)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/topsites/TopSitesAdapterTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/topsites/TopSitesAdapterTest.kt
new file mode 100644
index 0000000000..71776e6800
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/topsites/TopSitesAdapterTest.kt
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.topsites
+
+import mozilla.components.feature.top.sites.TopSite
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class TopSitesAdapterTest {
+
+ @Test
+ fun testDiffCallback() {
+ val topSite = TopSite.Default(
+ id = 1L,
+ title = "Title1",
+ url = "https://mozilla.org",
+ null,
+ )
+ val topSite2 = TopSite.Default(
+ id = 1L,
+ title = "Title2",
+ url = "https://mozilla.org",
+ null,
+ )
+
+ assertEquals(
+ TopSitesAdapter.TopSitesDiffCallback.getChangePayload(topSite, topSite2),
+ topSite.copy(title = "Title2"),
+ )
+
+ val topSite3 = TopSite.Default(
+ id = 2L,
+ title = "Title2",
+ url = "https://firefox.org",
+ null,
+ )
+
+ assertEquals(
+ TopSitesAdapter.TopSitesDiffCallback.getChangePayload(topSite, topSite3),
+ null,
+ )
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/topsites/TopSitesPagerAdapterTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/topsites/TopSitesPagerAdapterTest.kt
new file mode 100644
index 0000000000..6cbc3c58d2
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/topsites/TopSitesPagerAdapterTest.kt
@@ -0,0 +1,177 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.home.topsites
+
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.feature.top.sites.TopSite
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.home.sessioncontrol.AdapterItem.TopSitePagerPayload
+
+class TopSitesPagerAdapterTest {
+
+ private lateinit var topSitesPagerAdapter: TopSitesPagerAdapter
+
+ private val topSite = TopSite.Default(
+ id = 1L,
+ title = "Title1",
+ url = "https://mozilla.org",
+ null,
+ )
+
+ private val topSite2 = TopSite.Default(
+ id = 2L,
+ title = "Title2",
+ url = "https://mozilla.org",
+ null,
+ )
+
+ private val topSite3 = TopSite.Default(
+ id = 3L,
+ title = "Title3",
+ url = "https://firefox.org",
+ null,
+ )
+ private val topSite4 = TopSite.Default(
+ id = 4L,
+ title = "Title4",
+ url = "https://firefox.org",
+ null,
+ )
+
+ @Before
+ fun setup() {
+ topSitesPagerAdapter = spyk(TopSitesPagerAdapter(mockk(), mockk(), mockk()))
+ }
+
+ @Test
+ fun testDiffCallback() {
+ assertEquals(
+ TopSitesPagerAdapter.TopSiteListDiffCallback.getChangePayload(
+ listOf(topSite, topSite3),
+ listOf(topSite, topSite2),
+ ),
+ TopSitePagerPayload(setOf(Pair(1, topSite2))),
+ )
+ }
+
+ @Test
+ fun `GIVEN a payload with topSites for both pages WHEN getCurrentPageChanges THEN return topSites only for current page`() {
+ val payload = TopSitePagerPayload(
+ setOf(
+ Pair(0, topSite),
+ Pair(1, topSite2),
+ Pair(2, topSite3),
+ Pair(8, topSite4),
+ ),
+ )
+
+ val resultPage1: List<Pair<Int, TopSite>> =
+ topSitesPagerAdapter.getCurrentPageChanges(payload, 0)
+ val resultPage2: List<Pair<Int, TopSite>> =
+ topSitesPagerAdapter.getCurrentPageChanges(payload, 1)
+
+ assertEquals(
+ listOf(
+ Pair(0, topSite),
+ Pair(1, topSite2),
+ Pair(2, topSite3),
+ ),
+ resultPage1,
+ )
+
+ assertEquals(
+ listOf(Pair(8, topSite4)),
+ resultPage2,
+ )
+ }
+
+ @Test
+ fun `WHEN update is called to delete the 1st of 4 topSites THEN submitList will update 3 topSites`() {
+ val currentList = listOf(topSite, topSite2, topSite3, topSite4)
+ val topSitesAdapter: TopSitesAdapter = mockk()
+
+ every { topSitesAdapter.currentList } returns currentList
+ every { topSitesAdapter.submitList(any()) } just Runs
+
+ val removedTopSite = TopSite.Default(
+ id = -1L,
+ title = "REMOVED",
+ url = "https://firefox.org",
+ null,
+ )
+ val payload = TopSitePagerPayload(
+ setOf(
+ Pair(0, removedTopSite),
+ Pair(1, topSite2),
+ Pair(2, topSite3),
+ Pair(3, topSite4),
+ ),
+ )
+
+ topSitesPagerAdapter.update(payload, 0, topSitesAdapter)
+
+ val expected = listOf(topSite2, topSite3, topSite4)
+ verify { topSitesAdapter.submitList(expected) }
+ }
+
+ @Test
+ fun `WHEN update is called to delete the 4th of 4 topSites THEN submitList will update 1 topSite`() {
+ val currentList = listOf(topSite, topSite2, topSite3, topSite4)
+ val topSitesAdapter: TopSitesAdapter = mockk()
+
+ every { topSitesAdapter.currentList } returns currentList
+ every { topSitesAdapter.submitList(any()) } just Runs
+
+ val removedTopSite = TopSite.Default(
+ id = -1L,
+ title = "REMOVED",
+ url = "https://firefox.org",
+ null,
+ )
+ val payload = TopSitePagerPayload(
+ setOf(
+ Pair(3, removedTopSite),
+ ),
+ )
+
+ topSitesPagerAdapter.update(payload, 0, topSitesAdapter)
+
+ val expected = listOf(topSite, topSite2, topSite3)
+ verify { topSitesAdapter.submitList(expected) }
+ }
+
+ @Test
+ fun `WHEN update is called to update the 3rd of 4 topSites THEN submitList will contain 4 items`() {
+ val currentList = listOf(topSite, topSite2, topSite3, topSite4)
+ val topSitesAdapter: TopSitesAdapter = mockk()
+
+ every { topSitesAdapter.currentList } returns currentList
+ every { topSitesAdapter.submitList(any()) } just Runs
+
+ val changedTopSite = TopSite.Default(
+ id = 3L,
+ title = "CHANGED",
+ url = "https://firefox.org",
+ null,
+ )
+ val payload = TopSitePagerPayload(
+ setOf(
+ Pair(2, changedTopSite),
+ ),
+ )
+
+ topSitesPagerAdapter.update(payload, 0, topSitesAdapter)
+
+ val expected = listOf(topSite, topSite2, changedTopSite, topSite4)
+ verify { topSitesAdapter.submitList(expected) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/intent/ExternalDeepLinkIntentProcessorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/intent/ExternalDeepLinkIntentProcessorTest.kt
new file mode 100644
index 0000000000..dccbef1cbb
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/intent/ExternalDeepLinkIntentProcessorTest.kt
@@ -0,0 +1,42 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.intent
+
+import android.content.Intent
+import android.net.Uri
+import junit.framework.TestCase
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.BuildConfig
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class ExternalDeepLinkIntentProcessorTest : TestCase() {
+
+ @Test
+ fun `GIVEN a deeplink intent WHEN processing the intent THEN add the extra flags`() {
+ val processor = ExternalDeepLinkIntentProcessor()
+ val uri = Uri.parse(BuildConfig.DEEP_LINK_SCHEME + "://settings_wallpapers")
+ val intent = Intent("", uri)
+
+ val result = processor.process(intent)
+
+ assertTrue(result)
+ assertTrue((intent.flags and (Intent.FLAG_ACTIVITY_NEW_TASK) != 0))
+ assertTrue((intent.flags and (Intent.FLAG_ACTIVITY_CLEAR_TOP) != 0))
+ }
+
+ @Test
+ fun `GIVEN a non-deeplink intent WHEN processing the intent THEN do not add the extra flags`() {
+ val processor = ExternalDeepLinkIntentProcessor()
+ val intent = Intent("")
+
+ val result = processor.process(intent)
+
+ assertFalse(result)
+ assertFalse((intent.flags and (Intent.FLAG_ACTIVITY_NEW_TASK) != 0))
+ assertFalse((intent.flags and (Intent.FLAG_ACTIVITY_CLEAR_TOP) != 0))
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkAdapterTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkAdapterTest.kt
new file mode 100644
index 0000000000..80fd47b1c4
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkAdapterTest.kt
@@ -0,0 +1,171 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.bookmarks
+
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verifyOrder
+import mozilla.components.concept.storage.BookmarkNode
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.library.bookmarks.viewholders.BookmarkNodeViewHolder
+
+@RunWith(FenixRobolectricTestRunner::class)
+internal class BookmarkAdapterTest {
+
+ private lateinit var bookmarkAdapter: BookmarkAdapter
+
+ @Before
+ fun setup() {
+ bookmarkAdapter = spyk(
+ BookmarkAdapter(mockk(relaxed = true), mockk()),
+ )
+ }
+
+ @Test
+ fun `update adapter from tree of bookmark nodes, null tree returns empty list`() {
+ val tree = testFolder(
+ "123",
+ "root",
+ listOf(
+ testBookmarkItem("someFolder", "http://mozilla.org"),
+ testSeparator("123"),
+ testBookmarkItem("123", "https://www.mozilla.org/en-US/firefox/"),
+ ),
+ )
+ bookmarkAdapter.updateData(tree, BookmarkFragmentState.Mode.Normal())
+ bookmarkAdapter.updateData(null, BookmarkFragmentState.Mode.Normal())
+ verifyOrder {
+ bookmarkAdapter.updateData(tree, BookmarkFragmentState.Mode.Normal())
+ bookmarkAdapter.notifyItemRangeInserted(0, 2)
+ bookmarkAdapter.updateData(null, BookmarkFragmentState.Mode.Normal())
+ bookmarkAdapter.notifyItemRangeRemoved(0, 2)
+ }
+ }
+
+ @Test
+ fun `update adapter from tree of bookmark nodes, separators are excluded`() {
+ val sep1 = testSeparator("123")
+ val sep2 = testSeparator("123")
+ val item1 = testBookmarkItem("123", "http://mozilla.org")
+ val item2 = testBookmarkItem("123", "https://www.mozilla.org/en-US/firefox/")
+ val folder = testFolder("123", "root", title = "Mobile", children = listOf(item1, sep1, item2, sep2))
+ bookmarkAdapter.updateData(folder, BookmarkFragmentState.Mode.Normal())
+ verifyOrder {
+ bookmarkAdapter.updateData(folder, BookmarkFragmentState.Mode.Normal())
+ bookmarkAdapter.notifyItemRangeInserted(0, 2)
+ }
+
+ assertEquals(2, bookmarkAdapter.itemCount)
+ assertEquals(listOf(item1, item2), bookmarkAdapter.tree)
+ }
+
+ @Test
+ fun `update adapter from tree of bookmark nodes, folders are moved to the top`() {
+ val sep1 = testSeparator("123")
+ val item1 = testBookmarkItem("123", "http://mozilla.org")
+ val item2 = testBookmarkItem("123", "https://www.mozilla.org/en-US/firefox/")
+ val item3 = testBookmarkItem("123", "https://www.mozilla.org/en-US/firefox/2")
+ val item4 = testBookmarkItem("125", "https://www.mozilla.org/en-US/firefox/3")
+ val folder2 = testFolder("124", "123", title = "Mobile 2", children = emptyList())
+ val folder3 = testFolder("125", "123", title = "Mobile 3", children = listOf(item4))
+ val folder4 = testFolder("126", "123", title = "Mobile 3", children = emptyList())
+ val folder = testFolder(
+ "123",
+ "root",
+ title = "Mobile",
+ children = listOf(
+ folder4,
+ item1,
+ sep1,
+ item2,
+ folder2,
+ folder3,
+ item3,
+ ),
+ )
+ bookmarkAdapter.updateData(folder, BookmarkFragmentState.Mode.Normal())
+ verifyOrder {
+ bookmarkAdapter.updateData(folder, BookmarkFragmentState.Mode.Normal())
+ bookmarkAdapter.notifyItemRangeInserted(0, 6)
+ }
+
+ assertEquals(6, bookmarkAdapter.itemCount)
+ assertEquals(listOf(folder4, folder2, folder3, item1, item2, item3), bookmarkAdapter.tree)
+ }
+
+ @Test
+ fun `get item view type for different types of nodes`() {
+ val sep1 = testSeparator("123")
+ val item1 = testBookmarkItem("123", "https://www.mozilla.org/en-US/firefox/")
+ val folder1 = testFolder("124", "123", title = "Mobile 2", children = emptyList())
+ bookmarkAdapter.updateData(
+ testFolder("123", "root", listOf(sep1, item1, folder1)),
+ BookmarkFragmentState.Mode.Normal(),
+ )
+
+ assertEquals(2, bookmarkAdapter.itemCount)
+ // item1
+ assertEquals(BookmarkNodeViewHolder.LAYOUT_ID, bookmarkAdapter.getItemViewType(0))
+ // folder1
+ assertEquals(BookmarkNodeViewHolder.LAYOUT_ID, bookmarkAdapter.getItemViewType(1))
+ // sep is dropped during update
+ }
+
+ @Test
+ fun `items are the same if they have the same guids`() {
+ val item = testBookmarkItem("someFolder", "http://mozilla.org")
+ assertTrue(createSingleItemDiffUtil(item, item).areItemsTheSame(0, 0))
+ assertTrue(
+ createSingleItemDiffUtil(
+ item,
+ item.copy(title = "Wikipedia.org", url = "https://www.wikipedia.org"),
+ ).areItemsTheSame(0, 0),
+ )
+ assertFalse(
+ createSingleItemDiffUtil(
+ item,
+ item.copy(guid = "111"),
+ ).areItemsTheSame(0, 0),
+ )
+ }
+
+ @Test
+ fun `equal items have same contents unless their selected state changes`() {
+ val item = testBookmarkItem("someFolder", "http://mozilla.org")
+ assertTrue(createSingleItemDiffUtil(item, item).areContentsTheSame(0, 0))
+ assertFalse(
+ createSingleItemDiffUtil(item, item.copy(position = 1u)).areContentsTheSame(0, 0),
+ )
+ assertFalse(
+ createSingleItemDiffUtil(
+ item,
+ item,
+ oldMode = BookmarkFragmentState.Mode.Selecting(setOf(item)),
+ ).areContentsTheSame(0, 0),
+ )
+ assertFalse(
+ createSingleItemDiffUtil(
+ item,
+ item,
+ newMode = BookmarkFragmentState.Mode.Selecting(setOf(item)),
+ ).areContentsTheSame(0, 0),
+ )
+ }
+
+ private fun createSingleItemDiffUtil(
+ oldItem: BookmarkNode,
+ newItem: BookmarkNode,
+ oldMode: BookmarkFragmentState.Mode = BookmarkFragmentState.Mode.Normal(),
+ newMode: BookmarkFragmentState.Mode = BookmarkFragmentState.Mode.Normal(),
+ ): BookmarkAdapter.BookmarkDiffUtil {
+ return BookmarkAdapter.BookmarkDiffUtil(listOf(oldItem), listOf(newItem), oldMode, newMode)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkControllerTest.kt
new file mode 100644
index 0000000000..2b9b249686
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkControllerTest.kt
@@ -0,0 +1,564 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.bookmarks
+
+import android.content.ClipboardManager
+import android.content.Context
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavController
+import androidx.navigation.NavDestination
+import androidx.navigation.NavDirections
+import io.mockk.Runs
+import io.mockk.called
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkConstructor
+import io.mockk.runs
+import io.mockk.slot
+import io.mockk.spyk
+import io.mockk.unmockkConstructor
+import io.mockk.verify
+import io.mockk.verifyOrder
+import mozilla.appservices.places.BookmarkRoot
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.concept.storage.BookmarkNode
+import mozilla.components.concept.storage.BookmarkNodeType
+import mozilla.components.feature.tabs.TabsUseCases
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.BrowserDirection
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.R
+import org.mozilla.fenix.browser.browsingmode.BrowsingMode
+import org.mozilla.fenix.components.Services
+import org.mozilla.fenix.ext.bookmarkStorage
+import org.mozilla.fenix.ext.components
+
+@Suppress("TooManyFunctions", "LargeClass")
+class BookmarkControllerTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val scope = coroutinesTestRule.scope
+
+ private val bookmarkStore = spyk(BookmarkFragmentStore(BookmarkFragmentState(null)))
+ private val context: Context = mockk(relaxed = true)
+ private val clipboardManager: ClipboardManager = mockk(relaxUnitFun = true)
+ private val navController: NavController = mockk(relaxed = true)
+ private val sharedViewModel: BookmarksSharedViewModel = mockk()
+ private val tabsUseCases: TabsUseCases = mockk()
+ private val homeActivity: HomeActivity = mockk(relaxed = true)
+ private val services: Services = mockk(relaxed = true)
+ private val addNewTabUseCase: TabsUseCases.AddNewTabUseCase = mockk(relaxed = true)
+ private val navBackStackEntry: NavBackStackEntry = mockk(relaxed = true)
+ private val navDestination: NavDestination = mockk(relaxed = true)
+
+ private val item =
+ BookmarkNode(BookmarkNodeType.ITEM, "456", "123", 0u, "Mozilla", "http://mozilla.org", 0, null)
+ private val subfolder =
+ BookmarkNode(BookmarkNodeType.FOLDER, "987", "123", 0u, "Subfolder", null, 0, listOf())
+ private val childItem = BookmarkNode(
+ BookmarkNodeType.ITEM,
+ "987",
+ "123",
+ 2u,
+ "Firefox",
+ "https://www.mozilla.org/en-US/firefox/",
+ 0,
+ null,
+ )
+ private val tree = BookmarkNode(
+ BookmarkNodeType.FOLDER,
+ "123",
+ null,
+ 0u,
+ "Mobile",
+ null,
+ 0,
+ listOf(item, item, childItem, subfolder),
+ )
+ private val largeTree = BookmarkNode(
+ BookmarkNodeType.FOLDER,
+ "123",
+ null,
+ 0u,
+ "Mobile",
+ null,
+ 0,
+ List(WARN_OPEN_ALL_SIZE) { item },
+ )
+ private val root = BookmarkNode(
+ BookmarkNodeType.FOLDER,
+ BookmarkRoot.Root.id,
+ null,
+ 0u,
+ BookmarkRoot.Root.name,
+ null,
+ 0,
+ null,
+ )
+
+ @Before
+ fun setup() {
+ every { homeActivity.components.services } returns services
+ every { navController.currentDestination } returns NavDestination("").apply {
+ id = R.id.bookmarkFragment
+ }
+ every { navController.previousBackStackEntry } returns navBackStackEntry
+ every { navBackStackEntry.destination } returns navDestination
+ every { navDestination.id } returns R.id.browserFragment
+ every { bookmarkStore.dispatch(any()) } returns mockk()
+ every { sharedViewModel.selectedFolder = any() } just runs
+ every { tabsUseCases.addTab } returns addNewTabUseCase
+ }
+
+ @Test
+ fun `handleBookmarkChanged updates the selected bookmark node`() {
+ createController().handleBookmarkChanged(tree)
+
+ verify {
+ sharedViewModel.selectedFolder = tree
+ bookmarkStore.dispatch(BookmarkFragmentAction.Change(tree))
+ }
+ }
+
+ @Test
+ fun `WHEN handleBookmarkTapped is called with BrowserFragment THEN load the bookmark in current tab`() {
+ val flags = EngineSession.LoadUrlFlags.select(EngineSession.LoadUrlFlags.ALLOW_JAVASCRIPT_URL)
+
+ createController().handleBookmarkTapped(item)
+
+ verify {
+ homeActivity.openToBrowserAndLoad(
+ item.url!!,
+ false,
+ BrowserDirection.FromBookmarks,
+ flags = flags,
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN handleBookmarkTapped is called with HomeFragment THEN load the bookmark in new tab`() {
+ val flags = EngineSession.LoadUrlFlags.select(EngineSession.LoadUrlFlags.ALLOW_JAVASCRIPT_URL)
+
+ every { navDestination.id } returns R.id.homeFragment
+
+ createController().handleBookmarkTapped(item)
+
+ verify {
+ homeActivity.openToBrowserAndLoad(
+ item.url!!,
+ true,
+ BrowserDirection.FromBookmarks,
+ flags = flags,
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN handleBookmarkTapped is called with private browsing THEN load the bookmark in new tab`() {
+ every { homeActivity.browsingModeManager.mode } returns BrowsingMode.Private
+ val flags = EngineSession.LoadUrlFlags.select(EngineSession.LoadUrlFlags.ALLOW_JAVASCRIPT_URL)
+
+ createController().handleBookmarkTapped(item)
+
+ verify {
+ homeActivity.openToBrowserAndLoad(
+ item.url!!,
+ true,
+ BrowserDirection.FromBookmarks,
+ flags = flags,
+ )
+ }
+ }
+
+ @Test
+ fun `handleBookmarkTapped should respect browsing mode`() {
+ // if in normal mode, should be in normal mode
+ every { homeActivity.browsingModeManager.mode } returns BrowsingMode.Normal
+
+ val controller = createController()
+ controller.handleBookmarkTapped(item)
+ assertEquals(BrowsingMode.Normal, homeActivity.browsingModeManager.mode)
+
+ // if in private mode, should be in private mode
+ every { homeActivity.browsingModeManager.mode } returns BrowsingMode.Private
+
+ controller.handleBookmarkTapped(item)
+ assertEquals(BrowsingMode.Private, homeActivity.browsingModeManager.mode)
+ }
+
+ @Test
+ fun `handleBookmarkExpand should refresh and change the active bookmark node`() = runTestOnMain {
+ var loadBookmarkNodeInvoked = false
+ createController(
+ loadBookmarkNode = { _: String, _: Boolean ->
+ loadBookmarkNodeInvoked = true
+ tree
+ },
+ ).handleBookmarkExpand(tree)
+
+ assertTrue(loadBookmarkNodeInvoked)
+ coVerify {
+ sharedViewModel.selectedFolder = tree
+ bookmarkStore.dispatch(BookmarkFragmentAction.Change(tree))
+ }
+ }
+
+ @Test
+ fun `handleSelectionModeSwitch should invalidateOptionsMenu`() {
+ createController().handleSelectionModeSwitch()
+
+ verify {
+ homeActivity.invalidateOptionsMenu()
+ }
+ }
+
+ @Test
+ fun `handleBookmarkEdit should navigate to the 'Edit' fragment`() {
+ createController().handleBookmarkEdit(item)
+
+ verify {
+ navController.navigate(
+ BookmarkFragmentDirections.actionBookmarkFragmentToBookmarkEditFragment(
+ item.guid,
+ ),
+ null,
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN handling search THEN navigate to the search dialog fragment`() {
+ createController().handleSearch()
+
+ verify {
+ navController.navigate(
+ BookmarkFragmentDirections.actionGlobalSearchDialog(sessionId = null),
+ )
+ }
+ }
+
+ @Test
+ fun `handleBookmarkSelected dispatches Select action when selecting a non-root folder`() {
+ createController().handleBookmarkSelected(item)
+
+ verify {
+ bookmarkStore.dispatch(BookmarkFragmentAction.Select(item))
+ }
+ }
+
+ @Test
+ fun `handleBookmarkSelected should show a toast when selecting a root folder`() {
+ val errorMessage = context.getString(R.string.bookmark_cannot_edit_root)
+
+ var showSnackbarInvoked = false
+ createController(
+ showSnackbar = {
+ assertEquals(errorMessage, it)
+ showSnackbarInvoked = true
+ },
+ ).handleBookmarkSelected(root)
+
+ assertTrue(showSnackbarInvoked)
+ }
+
+ @Test
+ fun `handleBookmarkSelected does not select in Syncing mode`() {
+ every { bookmarkStore.state.mode } returns BookmarkFragmentState.Mode.Syncing
+
+ createController().handleBookmarkSelected(item)
+
+ verify { bookmarkStore.dispatch(BookmarkFragmentAction.Select(item)) wasNot called }
+ }
+
+ @Test
+ fun `handleBookmarkDeselected dispatches Deselect action`() {
+ createController().handleBookmarkDeselected(item)
+
+ verify {
+ bookmarkStore.dispatch(BookmarkFragmentAction.Deselect(item))
+ }
+ }
+
+ @Test
+ fun `handleCopyUrl should copy bookmark url to clipboard and show a toast`() {
+ val urlCopiedMessage = context.getString(R.string.url_copied)
+
+ var showSnackbarInvoked = false
+ createController(
+ showSnackbar = {
+ assertEquals(urlCopiedMessage, it)
+ showSnackbarInvoked = true
+ },
+ ).handleCopyUrl(item)
+
+ assertTrue(showSnackbarInvoked)
+ }
+
+ @Test
+ fun `handleBookmarkSharing should navigate to the 'Share' fragment`() {
+ val navDirectionsSlot = slot<NavDirections>()
+ every { navController.navigate(capture(navDirectionsSlot), null) } just Runs
+
+ createController().handleBookmarkSharing(item)
+
+ verify {
+ navController.navigate(navDirectionsSlot.captured, null)
+ }
+ }
+
+ @Test
+ fun `handleBookmarkTapped should open the bookmark`() {
+ val flags =
+ EngineSession.LoadUrlFlags.select(EngineSession.LoadUrlFlags.ALLOW_JAVASCRIPT_URL)
+
+ createController().handleBookmarkTapped(item)
+
+ verify {
+ homeActivity.openToBrowserAndLoad(
+ item.url!!,
+ false,
+ BrowserDirection.FromBookmarks,
+ flags = flags,
+ )
+ }
+ }
+
+ @Test
+ fun `handleOpeningBookmark should open the bookmark a new 'Normal' tab`() {
+ var showTabTrayInvoked = false
+ var openedToPrivateTabsPage: Boolean? = null
+ createController(
+ showTabTray = { openToPrivateTabsPage ->
+ openedToPrivateTabsPage = openToPrivateTabsPage
+ showTabTrayInvoked = true
+ },
+ ).handleOpeningBookmark(item, BrowsingMode.Normal)
+
+ assertTrue(showTabTrayInvoked)
+ assertNotNull(openedToPrivateTabsPage)
+ assertFalse(openedToPrivateTabsPage!!)
+ verifyOrder {
+ homeActivity.browsingModeManager.mode = BrowsingMode.Normal
+ addNewTabUseCase.invoke(item.url!!, private = false)
+ }
+ }
+
+ @Test
+ fun `handleOpeningBookmark should open the bookmark a new 'Private' tab`() {
+ var showTabTrayInvoked = false
+ var openedToPrivateTabsPage: Boolean? = null
+ createController(
+ showTabTray = { openToPrivateTabsPage ->
+ openedToPrivateTabsPage = openToPrivateTabsPage
+ showTabTrayInvoked = true
+ },
+ ).handleOpeningBookmark(item, BrowsingMode.Private)
+
+ assertTrue(showTabTrayInvoked)
+ assertNotNull(openedToPrivateTabsPage)
+ assertTrue(openedToPrivateTabsPage!!)
+ verifyOrder {
+ homeActivity.browsingModeManager.mode = BrowsingMode.Private
+ addNewTabUseCase.invoke(item.url!!, private = true)
+ }
+ }
+
+ @Test
+ fun `WHEN handle opening folder bookmarks THEN all bookmarks in folder is opened in normal tabs`() {
+ var showTabTrayInvoked = false
+ var tabsTrayOpenedToPrivateTabs: Boolean? = null
+ createController(
+ showTabTray = { openToPrivateTabsPage ->
+ tabsTrayOpenedToPrivateTabs = openToPrivateTabsPage
+ showTabTrayInvoked = true
+ },
+ loadBookmarkNode = { guid: String, _: Boolean ->
+ fun recurseFind(item: BookmarkNode, guid: String): BookmarkNode? {
+ if (item.guid == guid) {
+ return item
+ } else {
+ item.children?.iterator()?.forEach {
+ val res = recurseFind(it, guid)
+ if (res != null) {
+ return res
+ }
+ }
+ return null
+ }
+ }
+ recurseFind(tree, guid)
+ },
+ ).handleOpeningFolderBookmarks(tree, BrowsingMode.Normal)
+
+ assertTrue(showTabTrayInvoked)
+ assertNotNull(tabsTrayOpenedToPrivateTabs)
+ assertFalse(tabsTrayOpenedToPrivateTabs!!)
+ verifyOrder {
+ addNewTabUseCase.invoke(item.url!!, private = false)
+ addNewTabUseCase.invoke(item.url!!, private = false)
+ addNewTabUseCase.invoke(childItem.url!!, private = false)
+ homeActivity.browsingModeManager.mode = BrowsingMode.Normal
+ }
+ }
+
+ @Test
+ fun `WHEN handle opening folder bookmarks in private tabs THEN all bookmarks in folder is opened in private tabs`() {
+ var showTabTrayInvoked = false
+ var tabsTrayOpenedToPrivateTabs: Boolean? = null
+ createController(
+ showTabTray = { openToPrivateTabsPage ->
+ tabsTrayOpenedToPrivateTabs = openToPrivateTabsPage
+ showTabTrayInvoked = true
+ },
+ loadBookmarkNode = { guid: String, _: Boolean ->
+ fun recurseFind(item: BookmarkNode, guid: String): BookmarkNode? {
+ if (item.guid == guid) {
+ return item
+ } else {
+ item.children?.iterator()?.forEach {
+ val res = recurseFind(it, guid)
+ if (res != null) {
+ return res
+ }
+ }
+ return null
+ }
+ }
+ recurseFind(tree, guid)
+ },
+ ).handleOpeningFolderBookmarks(tree, BrowsingMode.Private)
+
+ assertTrue(showTabTrayInvoked)
+ assertNotNull(tabsTrayOpenedToPrivateTabs)
+ assertTrue(tabsTrayOpenedToPrivateTabs!!)
+ verifyOrder {
+ addNewTabUseCase.invoke(item.url!!, private = true)
+ addNewTabUseCase.invoke(item.url!!, private = true)
+ addNewTabUseCase.invoke(childItem.url!!, private = true)
+ homeActivity.browsingModeManager.mode = BrowsingMode.Private
+ }
+ }
+
+ @Test
+ fun `WHEN handle opening folder bookmarks with more than max items THEN warning is invoked`() {
+ var warningInvoked = false
+
+ mockkConstructor(DefaultBookmarkController::class)
+ createController(
+ loadBookmarkNode = { _: String, _: Boolean ->
+ largeTree
+ },
+ warnLargeOpenAll = { _: Int, _: () -> Unit -> warningInvoked = true },
+ ).handleOpeningFolderBookmarks(tree, BrowsingMode.Normal)
+
+ unmockkConstructor(DefaultBookmarkController::class)
+
+ assertTrue(warningInvoked)
+ }
+
+ @Test
+ fun `handleBookmarkDeletion for an item should properly call a passed in delegate`() {
+ var deleteBookmarkNodesInvoked = false
+ createController(
+ deleteBookmarkNodes = { nodes, removeEvent ->
+ assertEquals(setOf(item), nodes)
+ assertEquals(BookmarkRemoveType.SINGLE, removeEvent)
+ deleteBookmarkNodesInvoked = true
+ },
+ ).handleBookmarkDeletion(setOf(item), BookmarkRemoveType.SINGLE)
+
+ assertTrue(deleteBookmarkNodesInvoked)
+ }
+
+ @Test
+ fun `handleBookmarkDeletion for multiple bookmarks should properly call a passed in delegate`() {
+ var deleteBookmarkNodesInvoked = false
+ createController(
+ deleteBookmarkNodes = { nodes, removeEvent ->
+ assertEquals(setOf(item, subfolder), nodes)
+ assertEquals(BookmarkRemoveType.MULTIPLE, removeEvent)
+ deleteBookmarkNodesInvoked = true
+ },
+ ).handleBookmarkDeletion(setOf(item, subfolder), BookmarkRemoveType.MULTIPLE)
+
+ assertTrue(deleteBookmarkNodesInvoked)
+ }
+
+ @Test
+ fun `handleBookmarkDeletion for a folder should properly call the delete folder delegate`() {
+ var deleteBookmarkFolderInvoked = false
+ createController(
+ deleteBookmarkFolder = { nodes ->
+ assertEquals(setOf(subfolder), nodes)
+ deleteBookmarkFolderInvoked = true
+ },
+ ).handleBookmarkFolderDeletion(setOf(subfolder))
+
+ assertTrue(deleteBookmarkFolderInvoked)
+ }
+
+ @Test
+ fun `handleRequestSync dispatches actions in the correct order`() = runTestOnMain {
+ every { homeActivity.components.backgroundServices.accountManager } returns mockk(relaxed = true)
+ coEvery { homeActivity.bookmarkStorage.getBookmark(any()) } returns tree
+
+ createController().handleRequestSync()
+
+ verifyOrder {
+ bookmarkStore.dispatch(BookmarkFragmentAction.StartSync)
+ bookmarkStore.dispatch(BookmarkFragmentAction.FinishSync)
+ }
+ }
+
+ @Test
+ fun `handleBackPressed with one item in backstack should trigger handleBackPressed in NavController`() = runTestOnMain {
+ every { bookmarkStore.state.guidBackstack } returns listOf(tree.guid)
+ every { bookmarkStore.state.tree } returns tree
+
+ createController().handleBackPressed()
+
+ verify {
+ navController.popBackStack()
+ }
+ }
+
+ private fun createController(
+ loadBookmarkNode: suspend (String, Boolean) -> BookmarkNode? = { _, _ -> null },
+ showSnackbar: (String) -> Unit = { _ -> },
+ deleteBookmarkNodes: (Set<BookmarkNode>, BookmarkRemoveType) -> Unit = { _, _ -> },
+ deleteBookmarkFolder: (Set<BookmarkNode>) -> Unit = { _ -> },
+ showTabTray: (Boolean) -> Unit = { },
+ warnLargeOpenAll: (Int, () -> Unit) -> Unit = { _: Int, _: () -> Unit -> },
+ ): BookmarkController {
+ return DefaultBookmarkController(
+ activity = homeActivity,
+ navController = navController,
+ clipboardManager = clipboardManager,
+ scope = scope,
+ store = bookmarkStore,
+ sharedViewModel = sharedViewModel,
+ tabsUseCases = tabsUseCases,
+ loadBookmarkNode = loadBookmarkNode,
+ showSnackbar = showSnackbar,
+ deleteBookmarkNodes = deleteBookmarkNodes,
+ deleteBookmarkFolder = deleteBookmarkFolder,
+ showTabTray = showTabTray,
+ warnLargeOpenAll = warnLargeOpenAll,
+ )
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkDeselectNavigationListenerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkDeselectNavigationListenerTest.kt
new file mode 100644
index 0000000000..6d8b810d87
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkDeselectNavigationListenerTest.kt
@@ -0,0 +1,91 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.bookmarks
+
+import androidx.navigation.NavController
+import androidx.navigation.NavDestination
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.appservices.places.BookmarkRoot
+import mozilla.components.concept.storage.BookmarkNode
+import mozilla.components.concept.storage.BookmarkNodeType
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class BookmarkDeselectNavigationListenerTest {
+
+ private val basicNode = BookmarkNode(
+ BookmarkNodeType.ITEM,
+ BookmarkRoot.Root.id,
+ parentGuid = null,
+ position = 0u,
+ title = null,
+ url = null,
+ dateAdded = 0,
+ children = null,
+ )
+
+ @Test
+ fun `add listener on resume and remove on destroy`() {
+ val navController: NavController = mockk(relaxed = true)
+ val listener = BookmarkDeselectNavigationListener(navController, mockk(), mockk())
+
+ listener.onResume(mockk())
+ verify { navController.addOnDestinationChangedListener(listener) }
+
+ listener.onDestroy(mockk())
+ verify { navController.removeOnDestinationChangedListener(listener) }
+ }
+
+ @Test
+ fun `deselect when navigating to a different fragment`() {
+ val destination: NavDestination = mockk()
+ every { destination.id } returns R.id.homeFragment
+
+ val interactor: BookmarkViewInteractor = mockk(relaxed = true)
+ val listener = BookmarkDeselectNavigationListener(mockk(), mockk(), interactor)
+
+ listener.onDestinationChanged(mockk(), destination, mockk())
+ verify { interactor.onAllBookmarksDeselected() }
+ }
+
+ @Test
+ fun `deselect when navigating to a different folder`() {
+ val arguments = BookmarkFragmentArgs(currentRoot = "mock-guid").toBundle()
+ val destination: NavDestination = mockk()
+ every { destination.id } returns R.id.bookmarkFragment
+
+ val viewModel: BookmarksSharedViewModel = mockk()
+ val interactor: BookmarkViewInteractor = mockk(relaxed = true)
+ val listener = BookmarkDeselectNavigationListener(mockk(), viewModel, interactor)
+
+ every { viewModel.selectedFolder } returns null
+ listener.onDestinationChanged(mockk(), destination, arguments)
+ verify { interactor.onAllBookmarksDeselected() }
+
+ every { viewModel.selectedFolder } returns basicNode.copy(guid = "some-other-guid")
+ listener.onDestinationChanged(mockk(), destination, arguments)
+ verify { interactor.onAllBookmarksDeselected() }
+ }
+
+ @Test
+ fun `do not deselect when navigating to the same folder`() {
+ val arguments = BookmarkFragmentArgs(currentRoot = "mock-guid").toBundle()
+ val destination: NavDestination = mockk()
+ every { destination.id } returns R.id.bookmarkFragment
+
+ val viewModel: BookmarksSharedViewModel = mockk()
+ val interactor: BookmarkViewInteractor = mockk(relaxed = true)
+ val listener = BookmarkDeselectNavigationListener(mockk(), viewModel, interactor)
+
+ every { viewModel.selectedFolder } returns basicNode.copy(guid = "mock-guid")
+ listener.onDestinationChanged(mockk(), destination, arguments)
+ verify(exactly = 0) { interactor.onAllBookmarksDeselected() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractorTest.kt
new file mode 100644
index 0000000000..8593cad3cc
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractorTest.kt
@@ -0,0 +1,281 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.bookmarks
+
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.concept.storage.BookmarkNode
+import mozilla.components.concept.storage.BookmarkNodeType
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.telemetry.glean.testing.GleanTestRule
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.BookmarksManagement
+import org.mozilla.fenix.browser.browsingmode.BrowsingMode
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@SuppressWarnings("TooManyFunctions", "LargeClass")
+@RunWith(FenixRobolectricTestRunner::class) // For GleanTestRule
+class BookmarkFragmentInteractorTest {
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ private lateinit var interactor: BookmarkFragmentInteractor
+
+ private val bookmarkController: DefaultBookmarkController = mockk(relaxed = true)
+
+ private val item = BookmarkNode(BookmarkNodeType.ITEM, "456", "123", 0u, "Mozilla", "http://mozilla.org", 0, null)
+ private val separator = BookmarkNode(BookmarkNodeType.SEPARATOR, "789", "123", 1u, null, null, 0, null)
+ private val subfolder = BookmarkNode(BookmarkNodeType.FOLDER, "987", "123", 0u, "Subfolder", null, 0, listOf())
+ private val tree: BookmarkNode = BookmarkNode(
+ BookmarkNodeType.FOLDER,
+ "123",
+ null,
+ 0u,
+ "Mobile",
+ null,
+ 0,
+ listOf(item, separator, item, subfolder),
+ )
+
+ @Before
+ fun setup() {
+ interactor =
+ BookmarkFragmentInteractor(bookmarksController = bookmarkController)
+ }
+
+ @Test
+ fun `update bookmarks tree`() {
+ interactor.onBookmarksChanged(tree)
+
+ verify {
+ bookmarkController.handleBookmarkChanged(tree)
+ }
+ }
+
+ @Test
+ fun `open a bookmark item`() {
+ interactor.open(item)
+
+ verify { bookmarkController.handleBookmarkTapped(item) }
+ assertNotNull(BookmarksManagement.open.testGetValue())
+ assertEquals(1, BookmarksManagement.open.testGetValue()!!.size)
+ assertNull(BookmarksManagement.open.testGetValue()!!.single().extra)
+ }
+
+ @Test(expected = IllegalStateException::class)
+ fun `open a separator`() {
+ interactor.open(item.copy(type = BookmarkNodeType.SEPARATOR))
+ }
+
+ @Test
+ fun `expand a level of bookmarks`() {
+ interactor.open(tree)
+
+ verify {
+ bookmarkController.handleBookmarkExpand(tree)
+ }
+ }
+
+ @Test
+ fun `switch between bookmark selection modes`() {
+ interactor.onSelectionModeSwitch(BookmarkFragmentState.Mode.Normal())
+
+ verify {
+ bookmarkController.handleSelectionModeSwitch()
+ }
+ }
+
+ @Test
+ fun `press the edit bookmark button`() {
+ interactor.onEditPressed(item)
+
+ verify {
+ bookmarkController.handleBookmarkEdit(item)
+ }
+ }
+
+ @Test
+ fun `select a bookmark item`() {
+ interactor.select(item)
+
+ verify {
+ bookmarkController.handleBookmarkSelected(item)
+ }
+ }
+
+ @Test
+ fun `deselect a bookmark item`() {
+ interactor.deselect(item)
+
+ verify {
+ bookmarkController.handleBookmarkDeselected(item)
+ }
+ }
+
+ @Test
+ fun `deselectAll bookmark items`() {
+ interactor.onAllBookmarksDeselected()
+
+ verify {
+ bookmarkController.handleAllBookmarksDeselected()
+ }
+ }
+
+ @Test
+ fun `copy a bookmark item`() {
+ interactor.onCopyPressed(item)
+
+ verify { bookmarkController.handleCopyUrl(item) }
+ assertNotNull(BookmarksManagement.copied.testGetValue())
+ assertEquals(1, BookmarksManagement.copied.testGetValue()!!.size)
+ assertNull(BookmarksManagement.copied.testGetValue()!!.single().extra)
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `WHEN copying bookmark with folder THEN illegal argument exception is thrown`() {
+ interactor.onCopyPressed(tree)
+ }
+
+ @Test
+ fun `share a bookmark item`() {
+ interactor.onSharePressed(item)
+
+ verify { bookmarkController.handleBookmarkSharing(item) }
+ assertNotNull(BookmarksManagement.shared.testGetValue())
+ assertEquals(1, BookmarksManagement.shared.testGetValue()!!.size)
+ assertNull(BookmarksManagement.shared.testGetValue()!!.single().extra)
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `WHEN sharing bookmark with folder THEN illegal argument exception is thrown`() {
+ interactor.onSharePressed(tree)
+ }
+
+ @Test
+ fun `open a bookmark item in a new tab`() {
+ interactor.onOpenInNormalTab(item)
+
+ verify { bookmarkController.handleOpeningBookmark(item, BrowsingMode.Normal) }
+ assertNotNull(BookmarksManagement.openInNewTab.testGetValue())
+ assertEquals(1, BookmarksManagement.openInNewTab.testGetValue()!!.size)
+ assertNull(BookmarksManagement.openInNewTab.testGetValue()!!.single().extra)
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `WHEN open bookmark with folder THEN illegal argument exception is thrown`() {
+ interactor.onOpenInNormalTab(tree)
+ }
+
+ @Test
+ fun `open a bookmark item in a private tab`() {
+ interactor.onOpenInPrivateTab(item)
+
+ verify { bookmarkController.handleOpeningBookmark(item, BrowsingMode.Private) }
+ assertNotNull(BookmarksManagement.openInPrivateTab.testGetValue())
+ assertEquals(1, BookmarksManagement.openInPrivateTab.testGetValue()!!.size)
+ assertNull(BookmarksManagement.openInPrivateTab.testGetValue()!!.single().extra)
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `WHEN open bookmark in private with folder THEN illegal argument exception is thrown`() {
+ interactor.onOpenInPrivateTab(tree)
+ }
+
+ @Test
+ fun `WHEN open all bookmarks THEN call handle opening folder bookmarks`() {
+ interactor.onOpenAllInNewTabs(tree)
+
+ verify {
+ bookmarkController.handleOpeningFolderBookmarks(tree, BrowsingMode.Normal)
+ }
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `WHEN open all bookmarks with single item THEN illegal argument exception is thrown`() {
+ interactor.onOpenAllInNewTabs(item)
+ }
+
+ @Test
+ fun `WHEN open all bookmarks in private tabs THEN call handle opening folder bookmarks with private mode`() {
+ interactor.onOpenAllInPrivateTabs(tree)
+
+ verify {
+ bookmarkController.handleOpeningFolderBookmarks(tree, BrowsingMode.Private)
+ }
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `WHEN open all bookmarks in private with single item THEN illegal argument exception is thrown`() {
+ interactor.onOpenAllInPrivateTabs(item)
+ }
+
+ @Test
+ fun `delete a bookmark item`() {
+ interactor.onDelete(setOf(item))
+
+ verify {
+ bookmarkController.handleBookmarkDeletion(setOf(item), BookmarkRemoveType.SINGLE)
+ }
+ }
+
+ @Test(expected = IllegalStateException::class)
+ fun `delete a separator`() {
+ interactor.onDelete(setOf(item, item.copy(type = BookmarkNodeType.SEPARATOR)))
+ }
+
+ @Test
+ fun `delete a bookmark folder`() {
+ interactor.onDelete(setOf(subfolder))
+
+ verify {
+ bookmarkController.handleBookmarkFolderDeletion(setOf(subfolder))
+ }
+ }
+
+ @Test
+ fun `delete multiple bookmarks`() {
+ interactor.onDelete(setOf(item, subfolder))
+
+ verify {
+ bookmarkController.handleBookmarkDeletion(setOf(item, subfolder), BookmarkRemoveType.MULTIPLE)
+ }
+ }
+
+ @Test
+ fun `press the back button`() {
+ interactor.onBackPressed()
+
+ verify {
+ bookmarkController.handleBackPressed()
+ }
+ }
+
+ @Test
+ fun `request a sync`() {
+ interactor.onRequestSync()
+
+ verify {
+ bookmarkController.handleRequestSync()
+ }
+ }
+
+ @Test
+ fun `WHEN onSearch is called THEN call controller handleSearch`() {
+ assertNull(BookmarksManagement.searchIconTapped.testGetValue())
+ interactor.onSearch()
+
+ verify {
+ bookmarkController.handleSearch()
+ }
+ assertNotNull(BookmarksManagement.searchIconTapped.testGetValue())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentStoreTest.kt
new file mode 100644
index 0000000000..918aed1f6e
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentStoreTest.kt
@@ -0,0 +1,260 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.bookmarks
+
+import kotlinx.coroutines.test.runTest
+import mozilla.components.concept.storage.BookmarkNode
+import mozilla.components.concept.storage.BookmarkNodeType
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class BookmarkFragmentStoreTest {
+
+ @Test
+ fun `change the tree of bookmarks starting from an empty tree`() = runTest {
+ val initialState = BookmarkFragmentState(null)
+ val store = BookmarkFragmentStore(initialState)
+
+ assertEquals(store.state, BookmarkFragmentState(null, BookmarkFragmentState.Mode.Normal()))
+
+ store.dispatch(BookmarkFragmentAction.Change(tree)).join()
+
+ assertEquals(store.state.tree, tree)
+ assertEquals(store.state.mode, initialState.mode)
+ }
+
+ @Test
+ fun `change the tree of bookmarks starting from an existing tree`() = runTest {
+ val initialState = BookmarkFragmentState(tree)
+ val store = BookmarkFragmentStore(initialState)
+
+ assertEquals(store.state, BookmarkFragmentState(tree, BookmarkFragmentState.Mode.Normal()))
+
+ store.dispatch(BookmarkFragmentAction.Change(newTree)).join()
+
+ assertEquals(store.state.tree, newTree)
+ assertEquals(store.state.mode, initialState.mode)
+ }
+
+ @Test
+ fun `changing the tree of bookmarks adds the tree to the visited nodes`() = runTest {
+ val initialState = BookmarkFragmentState(null)
+ val store = BookmarkFragmentStore(initialState)
+
+ store.dispatch(BookmarkFragmentAction.Change(tree)).join()
+ store.dispatch(BookmarkFragmentAction.Change(subfolder)).join()
+
+ assertEquals(listOf(tree.guid, subfolder.guid), store.state.guidBackstack)
+ }
+
+ @Test
+ fun `changing to a node that is in the backstack removes backstack items after that node`() = runTest {
+ val initialState = BookmarkFragmentState(
+ null,
+ guidBackstack = listOf(tree.guid, subfolder.guid, item.guid),
+ )
+ val store = BookmarkFragmentStore(initialState)
+
+ store.dispatch(BookmarkFragmentAction.Change(tree)).join()
+
+ assertEquals(listOf(tree.guid), store.state.guidBackstack)
+ }
+
+ @Test
+ fun `change the tree of bookmarks to the same value`() = runTest {
+ val initialState = BookmarkFragmentState(tree)
+ val store = BookmarkFragmentStore(initialState)
+
+ assertEquals(store.state, BookmarkFragmentState(tree, BookmarkFragmentState.Mode.Normal()))
+
+ store.dispatch(BookmarkFragmentAction.Change(tree)).join()
+
+ assertEquals(store.state.tree, initialState.tree)
+ assertEquals(store.state.mode, initialState.mode)
+ }
+
+ @Test
+ fun `ensure selected items remain selected after a tree change`() = runTest {
+ val initialState = BookmarkFragmentState(tree, BookmarkFragmentState.Mode.Selecting(setOf(item, subfolder)))
+ val store = BookmarkFragmentStore(initialState)
+
+ store.dispatch(BookmarkFragmentAction.Change(newTree)).join()
+
+ assertEquals(store.state.tree, newTree)
+ assertEquals(store.state.mode, BookmarkFragmentState.Mode.Selecting(setOf(subfolder)))
+ }
+
+ @Test
+ fun `select and deselect a single bookmark changes the mode`() = runTest {
+ val initialState = BookmarkFragmentState(tree)
+ val store = BookmarkFragmentStore(initialState)
+
+ store.dispatch(BookmarkFragmentAction.Select(childItem)).join()
+
+ assertEquals(store.state, BookmarkFragmentState(tree, BookmarkFragmentState.Mode.Selecting(setOf(childItem))))
+
+ store.dispatch(BookmarkFragmentAction.Deselect(childItem)).join()
+
+ assertEquals(store.state, BookmarkFragmentState(tree, BookmarkFragmentState.Mode.Normal()))
+ }
+
+ @Test
+ fun `selecting the same item twice does nothing`() = runTest {
+ val initialState = BookmarkFragmentState(tree, BookmarkFragmentState.Mode.Selecting(setOf(item, subfolder)))
+ val store = BookmarkFragmentStore(initialState)
+
+ store.dispatch(BookmarkFragmentAction.Select(item)).join()
+
+ assertSame(initialState, store.state)
+ }
+
+ @Test
+ fun `deselecting an unselected bookmark does nothing`() = runTest {
+ val initialState = BookmarkFragmentState(tree, BookmarkFragmentState.Mode.Selecting(setOf(childItem)))
+ val store = BookmarkFragmentStore(initialState)
+
+ store.dispatch(BookmarkFragmentAction.Deselect(item)).join()
+
+ assertSame(initialState, store.state)
+ }
+
+ @Test
+ fun `deselecting while not in selecting mode does nothing`() = runTest {
+ val initialState = BookmarkFragmentState(tree, BookmarkFragmentState.Mode.Normal())
+ val store = BookmarkFragmentStore(initialState)
+
+ store.dispatch(BookmarkFragmentAction.Deselect(item)).join()
+
+ assertSame(initialState, store.state)
+ }
+
+ @Test
+ fun `deselect all bookmarks changes the mode`() = runTest {
+ val initialState = BookmarkFragmentState(tree, BookmarkFragmentState.Mode.Selecting(setOf(item, childItem)))
+ val store = BookmarkFragmentStore(initialState)
+
+ store.dispatch(BookmarkFragmentAction.DeselectAll).join()
+
+ assertEquals(store.state, initialState.copy(mode = BookmarkFragmentState.Mode.Normal()))
+ }
+
+ @Test
+ fun `deselect all bookmarks when none are selected`() = runTest {
+ val initialState = BookmarkFragmentState(tree, BookmarkFragmentState.Mode.Normal())
+ val store = BookmarkFragmentStore(initialState)
+
+ store.dispatch(BookmarkFragmentAction.DeselectAll)
+
+ assertSame(initialState, store.state)
+ }
+
+ @Test
+ fun `deleting bookmarks changes the mode`() = runTest {
+ val initialState = BookmarkFragmentState(tree, BookmarkFragmentState.Mode.Selecting(setOf(item, childItem)))
+ val store = BookmarkFragmentStore(initialState)
+
+ store.dispatch(BookmarkFragmentAction.Change(newTree)).join()
+
+ store.state.run {
+ assertEquals(tree, newTree)
+ assertEquals(mode, BookmarkFragmentState.Mode.Normal())
+ }
+ }
+
+ @Test
+ fun `selecting and deselecting bookmarks does not affect loading state`() = runTest {
+ val initialState = BookmarkFragmentState(tree, isLoading = true)
+ val store = BookmarkFragmentStore(initialState)
+
+ store.dispatch(BookmarkFragmentAction.Select(newTree)).join()
+ assertTrue(store.state.isLoading)
+
+ store.dispatch(BookmarkFragmentAction.Deselect(newTree)).join()
+ assertTrue(store.state.isLoading)
+
+ store.dispatch(BookmarkFragmentAction.DeselectAll).join()
+ assertTrue(store.state.isLoading)
+ }
+
+ @Test
+ fun `changing bookmarks disables loading state`() = runTest {
+ val initialState = BookmarkFragmentState(tree, isLoading = true)
+ val store = BookmarkFragmentStore(initialState)
+
+ store.dispatch(BookmarkFragmentAction.Change(newTree)).join()
+ assertFalse(store.state.isLoading)
+ }
+
+ @Test
+ fun `switching to Desktop Bookmarks folder sets showMenu state to false`() = runTest {
+ val initialState = BookmarkFragmentState(tree)
+ val store = BookmarkFragmentStore(initialState)
+
+ store.dispatch(BookmarkFragmentAction.Change(rootFolder)).join()
+
+ assertEquals(store.state.tree, rootFolder)
+ assertEquals(store.state.mode, BookmarkFragmentState.Mode.Normal(false))
+ }
+
+ @Test
+ fun `changing the tree or deselecting in Syncing mode should stay in Syncing mode`() = runTest {
+ val initialState = BookmarkFragmentState(tree)
+ val store = BookmarkFragmentStore(initialState)
+
+ store.dispatch(BookmarkFragmentAction.StartSync).join()
+ store.dispatch(BookmarkFragmentAction.Change(childItem))
+ assertEquals(BookmarkFragmentState.Mode.Syncing, store.state.mode)
+
+ store.dispatch(BookmarkFragmentAction.DeselectAll).join()
+ assertEquals(BookmarkFragmentState.Mode.Syncing, store.state.mode)
+ }
+
+ private val item = BookmarkNode(BookmarkNodeType.ITEM, "456", "123", 0u, "Mozilla", "http://mozilla.org", 0, null)
+ private val separator = BookmarkNode(BookmarkNodeType.SEPARATOR, "789", "123", 1u, null, null, 0, null)
+ private val subfolder = BookmarkNode(BookmarkNodeType.FOLDER, "987", "123", 0u, "Subfolder", null, 0, listOf())
+ private val childItem = BookmarkNode(
+ BookmarkNodeType.ITEM,
+ "987",
+ "123",
+ 2u,
+ "Firefox",
+ "https://www.mozilla.org/en-US/firefox/",
+ 0,
+ null,
+ )
+ private val tree = BookmarkNode(
+ BookmarkNodeType.FOLDER,
+ "123",
+ null,
+ 0u,
+ "Mobile",
+ null,
+ 0,
+ listOf(item, separator, childItem, subfolder),
+ )
+ private val newTree = BookmarkNode(
+ BookmarkNodeType.FOLDER,
+ "123",
+ null,
+ 0u,
+ "Mobile",
+ null,
+ 0,
+ listOf(separator, subfolder),
+ )
+ private val rootFolder = BookmarkNode(
+ BookmarkNodeType.FOLDER,
+ "root________",
+ null,
+ 0u,
+ "Desktop Bookmarks",
+ null,
+ 0,
+ listOf(subfolder),
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkItemMenuTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkItemMenuTest.kt
new file mode 100644
index 0000000000..f16d3cef42
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkItemMenuTest.kt
@@ -0,0 +1,147 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.bookmarks
+
+import android.content.Context
+import androidx.appcompat.view.ContextThemeWrapper
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import kotlinx.coroutines.runBlocking
+import mozilla.components.concept.menu.candidate.TextMenuCandidate
+import mozilla.components.concept.menu.candidate.TextStyle
+import mozilla.components.concept.storage.BookmarkNodeType
+import mozilla.components.support.ktx.android.content.getColorFromAttr
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.ext.bookmarkStorage
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.library.bookmarks.BookmarkItemMenu.Item
+
+@RunWith(FenixRobolectricTestRunner::class)
+class BookmarkItemMenuTest {
+
+ private lateinit var context: Context
+ private lateinit var menu: BookmarkItemMenu
+ private var lastItemTapped: Item? = null
+
+ @Before
+ fun setup() {
+ context = ContextThemeWrapper(testContext, R.style.NormalTheme)
+ menu = BookmarkItemMenu(context) {
+ lastItemTapped = it
+ }
+ }
+
+ @Test
+ fun `delete item has special styling`() = runBlocking {
+ var deleteItem: TextMenuCandidate? = null
+ mockkStatic("org.mozilla.fenix.ext.BookmarkNodeKt") {
+ every { any<Context>().bookmarkStorage } returns mockk(relaxed = true)
+
+ deleteItem = menu.menuItems(BookmarkNodeType.SEPARATOR, "").last()
+ }
+ assertNotNull(deleteItem)
+ assertEquals("Delete", deleteItem!!.text)
+ assertEquals(
+ TextStyle(color = context.getColorFromAttr(R.attr.textWarning)),
+ deleteItem!!.textStyle,
+ )
+
+ deleteItem!!.onClick()
+
+ assertEquals(Item.Delete, lastItemTapped)
+ }
+
+ @Test
+ fun `edit item appears for folders`() = runBlocking {
+ // empty folder
+ var emptyFolderItems: List<TextMenuCandidate>? = null
+ mockkStatic("org.mozilla.fenix.ext.BookmarkNodeKt") {
+ every { any<Context>().bookmarkStorage } returns mockk(relaxed = true)
+
+ emptyFolderItems = menu.menuItems(BookmarkNodeType.FOLDER, "")
+ }
+ assertNotNull(emptyFolderItems)
+ assertEquals(2, emptyFolderItems!!.size)
+
+ // not empty
+ var folderItems: List<TextMenuCandidate>? = null
+ mockkStatic("org.mozilla.fenix.ext.BookmarkNodeKt") {
+ coEvery { any<Context>().bookmarkStorage.getTree("")?.children } returns listOf(mockk(relaxed = true))
+
+ folderItems = menu.menuItems(BookmarkNodeType.FOLDER, "")
+ }
+ assertNotNull(folderItems)
+ assertEquals(4, folderItems!!.size)
+
+ val (edit, openAll, openAllPrivate, delete) = folderItems!!
+
+ assertEquals("Edit", edit.text)
+ assertEquals("Open all in new tabs", openAll.text)
+ assertEquals("Open all in private tabs", openAllPrivate.text)
+ assertEquals("Delete", delete.text)
+
+ edit.onClick()
+ assertEquals(Item.Edit, lastItemTapped)
+
+ openAll.onClick()
+ assertEquals(Item.OpenAllInNewTabs, lastItemTapped)
+
+ openAllPrivate.onClick()
+ assertEquals(Item.OpenAllInPrivateTabs, lastItemTapped)
+
+ delete.onClick()
+ assertEquals(Item.Delete, lastItemTapped)
+ }
+
+ @Test
+ fun `all item appears for sites`() = runBlocking {
+ var siteItems: List<TextMenuCandidate>? = null
+ mockkStatic("org.mozilla.fenix.ext.BookmarkNodeKt") {
+ every { any<Context>().bookmarkStorage } returns mockk(relaxed = true)
+
+ siteItems = menu.menuItems(BookmarkNodeType.ITEM, "")
+ }
+ assertNotNull(siteItems)
+ assertEquals(6, siteItems!!.size)
+ val (edit, copy, share, openInNewTab, openInPrivateTab, delete) = siteItems!!
+
+ assertEquals("Edit", edit.text)
+ assertEquals("Copy", copy.text)
+ assertEquals("Share", share.text)
+ assertEquals("Open in new tab", openInNewTab.text)
+ assertEquals("Open in private tab", openInPrivateTab.text)
+ assertEquals("Delete", delete.text)
+
+ edit.onClick()
+ assertEquals(Item.Edit, lastItemTapped)
+
+ copy.onClick()
+ assertEquals(Item.Copy, lastItemTapped)
+
+ share.onClick()
+ assertEquals(Item.Share, lastItemTapped)
+
+ openInNewTab.onClick()
+ assertEquals(Item.OpenInNewTab, lastItemTapped)
+
+ openInPrivateTab.onClick()
+ assertEquals(Item.OpenInPrivateTab, lastItemTapped)
+
+ delete.onClick()
+ assertEquals(Item.Delete, lastItemTapped)
+ }
+
+ private operator fun <T> List<T>.component6(): T {
+ return get(5)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/DesktopFoldersTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/DesktopFoldersTest.kt
new file mode 100644
index 0000000000..e0252673e5
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/DesktopFoldersTest.kt
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.bookmarks
+
+import android.content.Context
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import kotlinx.coroutines.test.runTest
+import mozilla.appservices.places.BookmarkRoot
+import mozilla.components.concept.storage.BookmarkNode
+import mozilla.components.concept.storage.BookmarkNodeType
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertSame
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class DesktopFoldersTest {
+
+ private lateinit var context: Context
+
+ private val basicNode = BookmarkNode(
+ type = BookmarkNodeType.FOLDER,
+ guid = BookmarkRoot.Root.id,
+ parentGuid = null,
+ title = BookmarkRoot.Root.name,
+ position = 0u,
+ url = null,
+ dateAdded = 0,
+ children = null,
+ )
+
+ @Before
+ fun setup() {
+ context = spyk(testContext)
+ every { context.components.core.bookmarksStorage } returns mockk()
+ }
+
+ @Test
+ fun `withRootTitle and do showMobileRoot`() {
+ assertEquals(testContext.getString(R.string.library_bookmarks), friendlyRootTitle(context, mockNodeWithTitle("root")))
+ assertEquals(testContext.getString(R.string.library_bookmarks), friendlyRootTitle(context, mockNodeWithTitle("mobile")))
+ assertEquals(testContext.getString(R.string.library_desktop_bookmarks_menu), friendlyRootTitle(context, mockNodeWithTitle("menu")))
+ assertEquals(testContext.getString(R.string.library_desktop_bookmarks_toolbar), friendlyRootTitle(context, mockNodeWithTitle("toolbar")))
+ assertEquals(testContext.getString(R.string.library_desktop_bookmarks_unfiled), friendlyRootTitle(context, mockNodeWithTitle("unfiled")))
+ }
+
+ @Test
+ fun `withRootTitle and do not showMobileRoot`() {
+ assertEquals(testContext.getString(R.string.library_desktop_bookmarks_root), friendlyRootTitle(context, mockNodeWithTitle("root"), false))
+ assertEquals(mockNodeWithTitle("mobile").title, friendlyRootTitle(context, mockNodeWithTitle("mobile"), false))
+ assertEquals(testContext.getString(R.string.library_desktop_bookmarks_menu), friendlyRootTitle(context, mockNodeWithTitle("menu"), false))
+ assertEquals(testContext.getString(R.string.library_desktop_bookmarks_toolbar), friendlyRootTitle(context, mockNodeWithTitle("toolbar"), false))
+ assertEquals(testContext.getString(R.string.library_desktop_bookmarks_unfiled), friendlyRootTitle(context, mockNodeWithTitle("unfiled"), false))
+ }
+
+ @Test
+ fun `withOptionalDesktopFolders other node`() = runTest {
+ val node = basicNode.copy(guid = "12345")
+ val desktopFolders = DesktopFolders(context, showMobileRoot = true)
+
+ assertSame(node, desktopFolders.withOptionalDesktopFolders(node))
+ }
+
+ private fun mockNodeWithTitle(title: String) = basicNode.copy(title = title)
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/UtilsKtTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/UtilsKtTest.kt
new file mode 100644
index 0000000000..f2dca99790
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/UtilsKtTest.kt
@@ -0,0 +1,116 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.bookmarks
+
+import mozilla.components.concept.storage.BookmarkNode
+import mozilla.components.concept.storage.BookmarkNodeType
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class UtilsKtTest {
+ @Test
+ fun `friendly root titles`() {
+ val url = testBookmarkItem("folder", "http://mozilla.org", "Mozilla")
+ assertEquals("Mozilla", friendlyRootTitle(testContext, url))
+
+ val folder = testFolder("456", "folder", null, "Folder")
+ assertEquals("Folder", friendlyRootTitle(testContext, folder))
+
+ val root = folder.copy(guid = "root________", title = "root")
+ assertEquals("Bookmarks", friendlyRootTitle(testContext, root, withMobileRoot = true))
+ assertEquals("Desktop Bookmarks", friendlyRootTitle(testContext, root, withMobileRoot = false))
+
+ val mobileRoot = folder.copy(guid = "mobile______", title = "mobile")
+ assertEquals("Bookmarks", friendlyRootTitle(testContext, mobileRoot, withMobileRoot = true))
+ assertEquals("mobile", friendlyRootTitle(testContext, mobileRoot, withMobileRoot = false))
+
+ val menuRoot = folder.copy(guid = "menu________", title = "menu")
+ assertEquals("Bookmarks Menu", friendlyRootTitle(testContext, menuRoot, withMobileRoot = true))
+ assertEquals("Bookmarks Menu", friendlyRootTitle(testContext, menuRoot, withMobileRoot = false))
+
+ val toolbarRoot = folder.copy(guid = "toolbar_____", title = "toolbar")
+ assertEquals("Bookmarks Toolbar", friendlyRootTitle(testContext, toolbarRoot, withMobileRoot = true))
+ assertEquals("Bookmarks Toolbar", friendlyRootTitle(testContext, toolbarRoot, withMobileRoot = false))
+
+ val unfiledRoot = folder.copy(guid = "unfiled_____", title = "unfiled")
+ assertEquals("Other Bookmarks", friendlyRootTitle(testContext, unfiledRoot, withMobileRoot = true))
+ assertEquals("Other Bookmarks", friendlyRootTitle(testContext, unfiledRoot, withMobileRoot = false))
+
+ val almostRoot = folder.copy(guid = "notRoot________", title = "root")
+ assertEquals("root", friendlyRootTitle(testContext, almostRoot, withMobileRoot = true))
+ assertEquals("root", friendlyRootTitle(testContext, almostRoot, withMobileRoot = false))
+ }
+
+ @Test
+ fun `flatNodeList various cases`() {
+ val url = testBookmarkItem("folder", "http://mozilla.org")
+ val url2 = testBookmarkItem("folder2", "http://mozilla.org")
+ assertEquals(emptyList<BookmarkNodeWithDepth>(), url.flatNodeList(null))
+
+ val root = testFolder("root", null, null)
+ assertEquals(listOf(BookmarkNodeWithDepth(0, root, null)), root.flatNodeList(null))
+ assertEquals(emptyList<BookmarkNodeWithDepth>(), root.flatNodeList("root"))
+
+ val folder = testFolder("folder", root.guid, listOf(url))
+ val folder3 = testFolder("folder3", "folder2", null)
+ val folder2 = testFolder("folder2", root.guid, listOf(folder3, url2))
+
+ val rootWithChildren = root.copy(children = listOf(folder, folder2))
+ assertEquals(
+ listOf(
+ BookmarkNodeWithDepth(0, rootWithChildren, null),
+ BookmarkNodeWithDepth(1, folder, "root"),
+ BookmarkNodeWithDepth(1, folder2, "root"),
+ BookmarkNodeWithDepth(2, folder3, "folder2"),
+ ),
+ rootWithChildren.flatNodeList(null),
+ )
+
+ assertEquals(
+ listOf(
+ BookmarkNodeWithDepth(0, rootWithChildren, null),
+ BookmarkNodeWithDepth(1, folder, "root"),
+ ),
+ rootWithChildren.flatNodeList(excludeSubtreeRoot = "folder2"),
+ )
+ }
+}
+
+internal fun testBookmarkItem(parentGuid: String, url: String, title: String = "Item for $url") = BookmarkNode(
+ BookmarkNodeType.ITEM,
+ "guid#${Math.random() * 1000}",
+ parentGuid,
+ 0u,
+ title,
+ url,
+ 0,
+ null,
+)
+
+internal fun testFolder(guid: String, parentGuid: String?, children: List<BookmarkNode>?, title: String = "Folder: $guid") = BookmarkNode(
+ BookmarkNodeType.FOLDER,
+ guid,
+ parentGuid,
+ 0u,
+ title,
+ null,
+ 0,
+ children,
+)
+
+internal fun testSeparator(parentGuid: String) = BookmarkNode(
+ BookmarkNodeType.SEPARATOR,
+ "guid#${Math.random() * 1000}",
+ parentGuid,
+ null,
+ null,
+ null,
+ 0,
+ null,
+)
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/viewholders/BookmarkNodeViewHolderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/viewholders/BookmarkNodeViewHolderTest.kt
new file mode 100644
index 0000000000..70a9a36a56
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/bookmarks/viewholders/BookmarkNodeViewHolderTest.kt
@@ -0,0 +1,220 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.bookmarks.viewholders
+
+import androidx.appcompat.content.res.AppCompatResources
+import io.mockk.Called
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import mozilla.components.browser.icons.BrowserIcons
+import mozilla.components.browser.icons.IconRequest
+import mozilla.components.concept.storage.BookmarkNode
+import mozilla.components.concept.storage.BookmarkNodeType
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.hideAndDisable
+import org.mozilla.fenix.ext.showAndEnable
+import org.mozilla.fenix.library.LibrarySiteItemView
+import org.mozilla.fenix.library.bookmarks.BookmarkFragmentInteractor
+import org.mozilla.fenix.library.bookmarks.BookmarkFragmentState
+import org.mozilla.fenix.library.bookmarks.BookmarkPayload
+
+class BookmarkNodeViewHolderTest {
+
+ @MockK private lateinit var interactor: BookmarkFragmentInteractor
+
+ @MockK(relaxed = true)
+ private lateinit var siteItemView: LibrarySiteItemView
+
+ @MockK private lateinit var icons: BrowserIcons
+ private lateinit var holder: BookmarkNodeViewHolder
+
+ private val item = BookmarkNode(
+ type = BookmarkNodeType.ITEM,
+ guid = "456",
+ parentGuid = "123",
+ position = 0u,
+ title = "Mozilla",
+ url = "https://www.mozilla.org",
+ dateAdded = 0,
+ children = listOf(),
+ )
+ private val folder = BookmarkNode(
+ type = BookmarkNodeType.FOLDER,
+ guid = "456",
+ parentGuid = "123",
+ position = 0u,
+ title = "Folder",
+ url = null,
+ dateAdded = 0,
+ children = listOf(),
+ )
+
+ private val falsePayload = BookmarkPayload(
+ titleChanged = false,
+ urlChanged = false,
+ selectedChanged = false,
+ modeChanged = false,
+ iconChanged = false,
+ )
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+
+ mockkStatic(AppCompatResources::class)
+ every { AppCompatResources.getDrawable(any(), any()) } returns mockk(relaxed = true)
+ every { siteItemView.context.components.core.icons } returns icons
+ every { icons.loadIntoView(siteItemView.iconView, any()) } returns mockk()
+
+ holder = BookmarkNodeViewHolder(siteItemView, interactor)
+ }
+
+ @After
+ fun teardown() {
+ unmockkStatic(AppCompatResources::class)
+ }
+
+ @Test
+ fun `binds views for unselected item`() {
+ val mode = BookmarkFragmentState.Mode.Normal()
+ holder.bind(item, mode, BookmarkPayload())
+
+ verify {
+ siteItemView.setSelectionInteractor(item, mode, interactor)
+ siteItemView.titleView.text = item.title
+ siteItemView.urlView.text = item.url
+ siteItemView.overflowView.showAndEnable()
+ siteItemView.changeSelected(false)
+ icons.loadIntoView(siteItemView.iconView, IconRequest(item.url!!))
+ }
+ }
+
+ @Test
+ fun `binds views for selected item for item`() {
+ val mode = BookmarkFragmentState.Mode.Selecting(setOf(item))
+ holder.bind(item, mode, BookmarkPayload())
+
+ verify {
+ siteItemView.setSelectionInteractor(item, mode, interactor)
+ siteItemView.titleView.text = item.title
+ siteItemView.urlView.text = item.url
+ siteItemView.overflowView.hideAndDisable()
+ siteItemView.changeSelected(true)
+ }
+ }
+
+ @Test
+ fun `bind with payload of no changes does not rebind views for item`() {
+ holder.bind(
+ item,
+ BookmarkFragmentState.Mode.Normal(),
+ falsePayload,
+ )
+
+ verify(inverse = true) {
+ siteItemView.titleView.text = item.title
+ siteItemView.urlView.text = item.url
+ siteItemView.overflowView.showAndEnable()
+ siteItemView.overflowView.hideAndDisable()
+ siteItemView.changeSelected(any())
+ }
+ verify { siteItemView.iconView wasNot Called }
+ }
+
+ @Test
+ fun `binding an item with a null title uses the url as the title for item`() {
+ val item = item.copy(title = null)
+ holder.bind(item, BookmarkFragmentState.Mode.Normal(), BookmarkPayload())
+
+ verify { siteItemView.titleView.text = item.url }
+ }
+
+ @Test
+ fun `binding an item with a blank title uses the url as the title for item`() {
+ val item = item.copy(title = " ")
+ holder.bind(item, BookmarkFragmentState.Mode.Normal(), BookmarkPayload())
+
+ verify { siteItemView.titleView.text = item.url }
+ }
+
+ @Test
+ fun `rebinds title if item title is null and the item url has changed for item`() {
+ val item = item.copy(title = null)
+ holder.bind(
+ item,
+ BookmarkFragmentState.Mode.Normal(),
+ BookmarkPayload(
+ titleChanged = false,
+ urlChanged = true,
+ selectedChanged = false,
+ modeChanged = false,
+ iconChanged = false,
+ ),
+ )
+
+ verify { siteItemView.titleView.text = item.url }
+ }
+
+ @Test
+ fun `rebinds title if item title is blank and the item url has changed for item`() {
+ val item = item.copy(title = " ")
+ holder.bind(
+ item,
+ BookmarkFragmentState.Mode.Normal(),
+ BookmarkPayload(
+ titleChanged = false,
+ urlChanged = true,
+ selectedChanged = false,
+ modeChanged = false,
+ iconChanged = false,
+ ),
+ )
+
+ verify { siteItemView.titleView.text = item.url }
+ }
+
+ @Test
+ fun `binds title and selected state for folder`() {
+ holder.bind(folder, BookmarkFragmentState.Mode.Normal(), BookmarkPayload())
+
+ verify {
+ siteItemView.titleView.text = folder.title
+ siteItemView.overflowView.showAndEnable()
+ siteItemView.changeSelected(false)
+ }
+
+ holder.bind(folder, BookmarkFragmentState.Mode.Selecting(setOf(folder)), BookmarkPayload())
+
+ verify {
+ siteItemView.titleView.text = folder.title
+ siteItemView.overflowView.hideAndDisable()
+ siteItemView.changeSelected(true)
+ }
+ }
+
+ @Test
+ fun `bind with payload of no changes does not rebind views for folder`() {
+ holder.bind(
+ folder,
+ BookmarkFragmentState.Mode.Normal(),
+ falsePayload,
+ )
+
+ verify(inverse = true) {
+ siteItemView.titleView.text = folder.title
+ siteItemView.overflowView.showAndEnable()
+ siteItemView.overflowView.hideAndDisable()
+ siteItemView.changeSelected(any())
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadAdapterTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadAdapterTest.kt
new file mode 100644
index 0000000000..c21e14c61d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadAdapterTest.kt
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.downloads
+
+import androidx.recyclerview.widget.RecyclerView
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class DownloadAdapterTest {
+
+ private lateinit var interactor: DownloadInteractor
+ private lateinit var adapter: DownloadAdapter
+
+ @Before
+ fun setup() {
+ interactor = mockk()
+ adapter = DownloadAdapter(interactor)
+
+ every { interactor.select(any()) } just Runs
+ }
+
+ @Test
+ fun `getItemCount should return the number of downloads`() {
+ val download = mockk<DownloadItem>()
+
+ assertEquals(0, adapter.itemCount)
+
+ adapter.updateDownloads(
+ downloads = listOf(download),
+ )
+ assertEquals(1, adapter.itemCount)
+ }
+
+ @Test
+ fun `updateData inserts item`() {
+ val download = mockk<DownloadItem> {
+ }
+ val observer = mockk<RecyclerView.AdapterDataObserver>(relaxed = true)
+ adapter.registerAdapterDataObserver(observer)
+ adapter.updateDownloads(
+ downloads = listOf(download),
+ )
+ verify { observer.onChanged() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadControllerTest.kt
new file mode 100644
index 0000000000..9f10c282b7
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadControllerTest.kt
@@ -0,0 +1,116 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.downloads
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.browser.state.state.content.DownloadState
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.browser.browsingmode.BrowsingMode
+
+class DownloadControllerTest {
+ private val downloadItem = DownloadItem(
+ id = "0",
+ url = "url",
+ fileName = "title",
+ filePath = "url",
+ size = "77",
+ contentType = "jpg",
+ status = DownloadState.Status.COMPLETED,
+ )
+ private val store: DownloadFragmentStore = mockk(relaxed = true)
+ private val state: DownloadFragmentState = mockk(relaxed = true)
+
+ private val openToFileManager: (DownloadItem, BrowsingMode?) -> Unit = { item, mode ->
+ openToFileManagerCapturedItem = item
+ openToFileManagerCapturedMode = mode
+ }
+ private var openToFileManagerCapturedItem: DownloadItem? = null
+ private var openToFileManagerCapturedMode: BrowsingMode? = null
+
+ private val invalidateOptionsMenu: () -> Unit = { wasInvalidateOptionsMenuCalled = true }
+ private var wasInvalidateOptionsMenuCalled = false
+
+ private val deleteDownloadItems: (Set<DownloadItem>) -> Unit = { deleteDownloadItemsCapturedItems = it }
+ private var deleteDownloadItemsCapturedItems = emptySet<DownloadItem>()
+
+ private val controller = DefaultDownloadController(
+ store,
+ openToFileManager,
+ invalidateOptionsMenu,
+ deleteDownloadItems,
+ )
+
+ @Before
+ fun setUp() {
+ every { store.state } returns state
+ }
+
+ @Test
+ fun onPressDownloadItemInNormalMode() {
+ controller.handleOpen(downloadItem)
+
+ assertEquals(downloadItem, openToFileManagerCapturedItem)
+ assertEquals(null, openToFileManagerCapturedMode)
+ }
+
+ @Test
+ fun onOpenItemInNormalMode() {
+ controller.handleOpen(downloadItem, BrowsingMode.Normal)
+
+ assertEquals(downloadItem, openToFileManagerCapturedItem)
+ assertEquals(BrowsingMode.Normal, openToFileManagerCapturedMode)
+ }
+
+ @Test
+ fun onBackPressedInNormalMode() {
+ every { state.mode } returns DownloadFragmentState.Mode.Normal
+
+ assertFalse(controller.handleBackPressed())
+ }
+
+ @Test
+ fun onPressDownloadItemInEditMode() {
+ every { state.mode } returns DownloadFragmentState.Mode.Editing(setOf())
+
+ controller.handleSelect(downloadItem)
+
+ verify {
+ store.dispatch(DownloadFragmentAction.AddItemForRemoval(downloadItem))
+ }
+ }
+
+ @Test
+ fun onPressSelectedDownloadItemInEditMode() {
+ every { state.mode } returns DownloadFragmentState.Mode.Editing(setOf(downloadItem))
+
+ controller.handleDeselect(downloadItem)
+
+ verify {
+ store.dispatch(DownloadFragmentAction.RemoveItemForRemoval(downloadItem))
+ }
+ }
+
+ @Test
+ fun onModeSwitched() {
+ controller.handleModeSwitched()
+
+ assertTrue(wasInvalidateOptionsMenuCalled)
+ }
+
+ @Test
+ fun onDeleteSome() {
+ val itemsToDelete = setOf(downloadItem)
+
+ controller.handleDeleteSome(itemsToDelete)
+
+ assertEquals(itemsToDelete, deleteDownloadItemsCapturedItems)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadFragmentStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadFragmentStoreTest.kt
new file mode 100644
index 0000000000..cb960e7740
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadFragmentStoreTest.kt
@@ -0,0 +1,86 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.downloads
+
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.state.state.content.DownloadState
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotSame
+import org.junit.Test
+
+class DownloadFragmentStoreTest {
+ private val downloadItem = DownloadItem(
+ id = "0",
+ url = "url",
+ fileName = "title",
+ filePath = "url",
+ size = "77",
+ contentType = "jpg",
+ status = DownloadState.Status.COMPLETED,
+ )
+ private val newDownloadItem = DownloadItem(
+ id = "1",
+ url = "url",
+ fileName = "title",
+ filePath = "url",
+ size = "77",
+ contentType = "jpg",
+ status = DownloadState.Status.COMPLETED,
+ )
+
+ @Test
+ fun exitEditMode() = runTest {
+ val initialState = oneItemEditState()
+ val store = DownloadFragmentStore(initialState)
+
+ store.dispatch(DownloadFragmentAction.ExitEditMode).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(store.state.mode, DownloadFragmentState.Mode.Normal)
+ }
+
+ @Test
+ fun itemAddedForRemoval() = runTest {
+ val initialState = emptyDefaultState()
+ val store = DownloadFragmentStore(initialState)
+
+ store.dispatch(DownloadFragmentAction.AddItemForRemoval(newDownloadItem)).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(
+ store.state.mode,
+ DownloadFragmentState.Mode.Editing(setOf(newDownloadItem)),
+ )
+ }
+
+ @Test
+ fun removeItemForRemoval() = runTest {
+ val initialState = twoItemEditState()
+ val store = DownloadFragmentStore(initialState)
+
+ store.dispatch(DownloadFragmentAction.RemoveItemForRemoval(newDownloadItem)).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(store.state.mode, DownloadFragmentState.Mode.Editing(setOf(downloadItem)))
+ }
+
+ private fun emptyDefaultState(): DownloadFragmentState = DownloadFragmentState(
+ items = listOf(),
+ mode = DownloadFragmentState.Mode.Normal,
+ pendingDeletionIds = emptySet(),
+ isDeletingItems = false,
+ )
+
+ private fun oneItemEditState(): DownloadFragmentState = DownloadFragmentState(
+ items = listOf(),
+ mode = DownloadFragmentState.Mode.Editing(setOf(downloadItem)),
+ pendingDeletionIds = emptySet(),
+ isDeletingItems = false,
+ )
+
+ private fun twoItemEditState(): DownloadFragmentState = DownloadFragmentState(
+ items = listOf(),
+ mode = DownloadFragmentState.Mode.Editing(setOf(downloadItem, newDownloadItem)),
+ pendingDeletionIds = emptySet(),
+ isDeletingItems = false,
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadFragmentTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadFragmentTest.kt
new file mode 100644
index 0000000000..dbf189345a
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadFragmentTest.kt
@@ -0,0 +1,182 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.downloads
+
+import android.os.Environment
+import io.mockk.every
+import io.mockk.mockk
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.content.DownloadState
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import java.io.File
+
+@Suppress("DEPRECATION")
+@RunWith(FenixRobolectricTestRunner::class)
+class DownloadFragmentTest {
+
+ @Test
+ fun `downloads are sorted from newest to oldest`() {
+ val downloadedFile1 = File(
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
+ "1.pdf",
+ )
+
+ val downloadedFile2 = File(
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
+ "2.pdf",
+ )
+
+ val downloadedFile3 = File(
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
+ "3.pdf",
+ )
+
+ downloadedFile1.createNewFile()
+ downloadedFile2.createNewFile()
+ downloadedFile3.createNewFile()
+
+ val fragment = DownloadFragment()
+
+ val expectedList = listOf(
+ DownloadItem(
+ id = "3",
+ url = "url",
+ fileName = "3.pdf",
+ filePath = downloadedFile3.path,
+ size = "0",
+ contentType = null,
+ status = DownloadState.Status.COMPLETED,
+ ),
+ DownloadItem(
+ id = "2",
+ url = "url",
+ fileName = "2.pdf",
+ filePath = downloadedFile2.path,
+ size = "0",
+ contentType = null,
+ status = DownloadState.Status.COMPLETED,
+ ),
+ DownloadItem(
+ id = "1",
+ url = "url",
+ fileName = "1.pdf",
+ filePath = downloadedFile1.path,
+ size = "0",
+ contentType = null,
+ status = DownloadState.Status.COMPLETED,
+ ),
+ )
+
+ val state: BrowserState = mockk(relaxed = true)
+
+ every { state.downloads } returns mapOf(
+ "1" to DownloadState(
+ id = "1",
+ createdTime = 1,
+ url = "url",
+ fileName = "1.pdf",
+ status = DownloadState.Status.COMPLETED,
+ ),
+ "2" to DownloadState(
+ id = "2",
+ createdTime = 2,
+ url = "url",
+ fileName = "2.pdf",
+ status = DownloadState.Status.COMPLETED,
+ ),
+ "3" to DownloadState(
+ id = "3",
+ createdTime = 3,
+ url = "url",
+ fileName = "3.pdf",
+ status = DownloadState.Status.COMPLETED,
+ ),
+ )
+
+ val list = fragment.provideDownloads(state)
+
+ assertEquals(expectedList, list)
+
+ downloadedFile1.delete()
+ downloadedFile2.delete()
+ downloadedFile3.delete()
+ }
+
+ @Test
+ fun `downloads with null content length don't crash`() {
+ val downloadedFile0 = File(
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
+ "1.pdf",
+ )
+
+ downloadedFile0.createNewFile()
+
+ val fragment = DownloadFragment()
+
+ val expectedList = listOf(
+ DownloadItem(
+ id = "1",
+ url = "url",
+ fileName = "1.pdf",
+ filePath = downloadedFile0.path,
+ size = "0",
+ contentType = null,
+ status = DownloadState.Status.COMPLETED,
+ ),
+ )
+
+ val state: BrowserState = mockk(relaxed = true)
+
+ every { state.downloads } returns mapOf(
+ "1" to DownloadState(
+ id = "1",
+ createdTime = 1,
+ url = "url",
+ fileName = "1.pdf",
+ contentLength = null,
+ status = DownloadState.Status.COMPLETED,
+ ),
+ )
+
+ val list = fragment.provideDownloads(state)
+ assertEquals(expectedList[0].size, list[0].size)
+ }
+
+ @Test
+ fun `WHEN two download states point to the same existing file THEN only one download item is displayed`() {
+ val fragment = DownloadFragment()
+ val downloadedFile0 = File(
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
+ "1.pdf",
+ )
+ val state: BrowserState = mockk(relaxed = true)
+ every { state.downloads } returns mapOf(
+ "1" to DownloadState(
+ id = "1",
+ createdTime = 1,
+ url = "url",
+ fileName = "1.pdf",
+ contentLength = 100,
+ status = DownloadState.Status.COMPLETED,
+ ),
+ "2" to DownloadState(
+ id = "2",
+ createdTime = 2,
+ url = "url",
+ fileName = "1.pdf",
+ contentLength = 100,
+ status = DownloadState.Status.COMPLETED,
+ ),
+ )
+
+ downloadedFile0.createNewFile()
+ val providedList = fragment.provideDownloads(state)
+
+ assertEquals(1, providedList.size)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadInteractorTest.kt
new file mode 100644
index 0000000000..75a288348e
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadInteractorTest.kt
@@ -0,0 +1,86 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.downloads
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verifyAll
+import mozilla.components.browser.state.state.content.DownloadState
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class DownloadInteractorTest {
+ private val downloadItem = DownloadItem(
+ id = "0",
+ url = "url",
+ fileName = "title",
+ filePath = "filePath",
+ size = "5.6 mb",
+ contentType = "png",
+ status = DownloadState.Status.COMPLETED,
+ )
+ val controller: DownloadController = mockk(relaxed = true)
+ val interactor = DownloadInteractor(controller)
+
+ @Test
+ fun onOpen() {
+ interactor.open(downloadItem)
+
+ verifyAll {
+ controller.handleOpen(downloadItem)
+ }
+ }
+
+ @Test
+ fun onSelect() {
+ interactor.select(downloadItem)
+
+ verifyAll {
+ controller.handleSelect(downloadItem)
+ }
+ }
+
+ @Test
+ fun onDeselect() {
+ interactor.deselect(downloadItem)
+
+ verifyAll {
+ controller.handleDeselect(downloadItem)
+ }
+ }
+
+ @Test
+ fun onBackPressed() {
+ every {
+ controller.handleBackPressed()
+ } returns true
+
+ val backpressHandled = interactor.onBackPressed()
+
+ verifyAll {
+ controller.handleBackPressed()
+ }
+ assertTrue(backpressHandled)
+ }
+
+ @Test
+ fun onModeSwitched() {
+ interactor.onModeSwitched()
+
+ verifyAll {
+ controller.handleModeSwitched()
+ }
+ }
+
+ @Test
+ fun onDeleteSome() {
+ val items = setOf(downloadItem)
+
+ interactor.onDeleteSome(items)
+ verifyAll {
+ controller.handleDeleteSome(items)
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/HistoryDataSourceTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/HistoryDataSourceTest.kt
new file mode 100644
index 0000000000..7202f20eaf
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/HistoryDataSourceTest.kt
@@ -0,0 +1,207 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.history
+
+import mozilla.components.concept.storage.HistoryMetadataKey
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.mozilla.fenix.components.history.HistoryDB
+
+class HistoryDataSourceTest {
+ private val testCases = listOf(
+ listOf<Int>() to listOf(),
+
+ listOf(1) to listOf(
+ TestHistory.Regular("http://www.mozilla.com"),
+ ),
+ listOf(1, 2) to listOf(
+ TestHistory.Regular("http://www.mozilla.com"),
+ TestHistory.Regular("http://www.mozilla.com/2"),
+ ),
+ listOf(1, 2) to listOf(
+ TestHistory.Metadata("http://www.mozilla.com"),
+ TestHistory.Regular("http://www.mozilla.com/2"),
+ ),
+ listOf(1, 2, 3) to listOf(
+ TestHistory.Metadata("http://www.mozilla.com"),
+ TestHistory.Regular("http://www.mozilla.com/2"),
+ TestHistory.Metadata("http://www.mozilla.com"),
+ ),
+ listOf(1, 2, 3, 4, 5) to listOf(
+ TestHistory.Metadata("http://www.mozilla.com"),
+ TestHistory.Regular("http://www.mozilla.com/2"),
+ TestHistory.Metadata("http://www.mozilla.com"),
+ TestHistory.Regular("http://www.mozilla.com/3"),
+ TestHistory.Regular("http://www.mozilla.com/2"),
+ ),
+ listOf(1, 2, 3) to listOf(
+ TestHistory.Metadata("http://www.mozilla.com"),
+ TestHistory.Regular("http://www.mozilla.com/2"),
+ TestHistory.Group("firefox", items = listOf()),
+ ),
+ listOf(1, 2, 3) to listOf(
+ TestHistory.Metadata("http://www.mozilla.com"),
+ TestHistory.Regular("http://www.mozilla.com/2"),
+ TestHistory.Group(
+ "firefox",
+ items = listOf(
+ "http://www.firefox.com",
+ ),
+ ),
+ ),
+ listOf(1, 2, 7) to listOf(
+ TestHistory.Metadata("http://www.mozilla.com"),
+ TestHistory.Regular("http://www.mozilla.com/2"),
+ TestHistory.Group(
+ "firefox",
+ items = listOf(
+ "http://www.firefox.com",
+ "http://www.firefox.com/2",
+ "http://www.firefox.com/3",
+ "http://www.firefox.com/4",
+ "http://www.firefox.com/5",
+ ),
+ ),
+ ),
+ listOf(5, 6, 7) to listOf(
+ TestHistory.Group(
+ "firefox",
+ items = listOf(
+ "http://www.firefox.com",
+ "http://www.firefox.com/2",
+ "http://www.firefox.com/3",
+ "http://www.firefox.com/4",
+ "http://www.firefox.com/5",
+ ),
+ ),
+ TestHistory.Metadata("http://www.mozilla.com"),
+ TestHistory.Regular("http://www.mozilla.com/2"),
+ ),
+ listOf(1, 6, 7) to listOf(
+ TestHistory.Metadata("http://www.mozilla.com"),
+ TestHistory.Group(
+ "firefox",
+ items = listOf(
+ "http://www.firefox.com",
+ "http://www.firefox.com/2",
+ "http://www.firefox.com/3",
+ "http://www.firefox.com/4",
+ "http://www.firefox.com/5",
+ ),
+ ),
+ TestHistory.Regular("http://www.mozilla.com/2"),
+ ),
+ listOf(1, 6, 8, 9) to listOf(
+ TestHistory.Metadata("http://www.mozilla.com"),
+ TestHistory.Group(
+ "firefox",
+ items = listOf(
+ "http://www.firefox.com",
+ "http://www.firefox.com/2",
+ "http://www.firefox.com/3",
+ "http://www.firefox.com/4",
+ "http://www.firefox.com/5",
+ ),
+ ),
+ TestHistory.Group(
+ "mdn",
+ items = listOf(
+ "https://developer.mozilla.org/en-US/1",
+ "https://developer.mozilla.org/en-US/2",
+ ),
+ ),
+ TestHistory.Regular("http://www.mozilla.com/2"),
+ ),
+ listOf(1) to listOf(
+ TestHistory.Group(
+ "mozilla",
+ items = listOf(
+ "http://www.mozilla.com",
+ ),
+ ),
+ ),
+ )
+
+ @Test
+ fun `assign positions basics - initial offset`() {
+ testCases.forEach {
+ verifyPositions(it.first, offset = 0, it.second)
+ }
+ }
+
+ @Test
+ fun `assign position basics - positive offset`() {
+ val offset = 25
+ testCases.forEach {
+ verifyPositions(it.first.map { pos -> pos + offset }, offset = offset, it.second)
+ }
+ }
+
+ @Test
+ fun `assign position basics - negative offset`() {
+ // Even though conceptually it doesn't make sense for us to handle negative offsets,
+ // as far as simple positioning logic is concerned there's no harm in doing the naive thing.
+ // Assertions around offset being a positive value should happen elsewhere, before we're
+ // even dealing with positions.
+ val offset = -25
+ testCases.forEach {
+ verifyPositions(it.first.map { pos -> pos + offset }, offset = offset, it.second)
+ }
+ }
+
+ private fun verifyPositions(expectedPositions: List<Int>, offset: Int, history: List<TestHistory>) {
+ assertEquals(
+ "For case $history with offset $offset",
+ expectedPositions,
+ history.toHistoryDB().positionWithOffset(offset).map { it.position },
+ )
+ }
+
+ private sealed class TestHistory {
+ data class Regular(val url: String) : TestHistory()
+ data class Metadata(val url: String) : TestHistory()
+ data class Group(val title: String, val items: List<String>) : TestHistory()
+ }
+
+ // For position tests, we just care about the basic tree structure here,
+ // the details (view times, timestamps, etc) don't matter.
+ private fun List<TestHistory>.toHistoryDB(): List<HistoryDB> {
+ return this.map {
+ when (it) {
+ is TestHistory.Regular -> {
+ HistoryDB.Regular(
+ title = it.url,
+ url = it.url,
+ visitedAt = 0,
+ )
+ }
+ is TestHistory.Metadata -> {
+ HistoryDB.Metadata(
+ title = it.url,
+ url = it.url,
+ visitedAt = 0,
+ totalViewTime = 0,
+ historyMetadataKey = HistoryMetadataKey(url = it.url),
+ )
+ }
+ is TestHistory.Group -> {
+ HistoryDB.Group(
+ title = it.title,
+ visitedAt = 0,
+ items = it.items.map { item ->
+ HistoryDB.Metadata(
+ title = item,
+ url = item,
+ visitedAt = 0,
+ totalViewTime = 0,
+ historyMetadataKey = HistoryMetadataKey(url = item),
+ )
+ },
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/HistoryFragmentStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/HistoryFragmentStoreTest.kt
new file mode 100644
index 0000000000..dae2a0598e
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/HistoryFragmentStoreTest.kt
@@ -0,0 +1,235 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.history
+
+import kotlinx.coroutines.test.runTest
+import mozilla.components.support.test.ext.joinBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotSame
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class HistoryFragmentStoreTest {
+ private val historyItem = History.Regular(0, "title", "url", 0.toLong(), HistoryItemTimeGroup.timeGroupForTimestamp(0))
+ private val newHistoryItem = History.Regular(1, "title", "url", 0.toLong(), HistoryItemTimeGroup.timeGroupForTimestamp(0))
+ private val pendingDeletionItem = historyItem.toPendingDeletionHistory()
+
+ @Test
+ fun exitEditMode() = runTest {
+ val initialState = oneItemEditState()
+ val store = HistoryFragmentStore(initialState)
+
+ store.dispatch(HistoryFragmentAction.ExitEditMode).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(store.state.mode, HistoryFragmentState.Mode.Normal)
+ }
+
+ @Test
+ fun itemAddedForRemoval() = runTest {
+ val initialState = emptyDefaultState()
+ val store = HistoryFragmentStore(initialState)
+
+ store.dispatch(HistoryFragmentAction.AddItemForRemoval(newHistoryItem)).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(
+ store.state.mode,
+ HistoryFragmentState.Mode.Editing(setOf(newHistoryItem)),
+ )
+ }
+
+ @Test
+ fun removeItemForRemoval() = runTest {
+ val initialState = twoItemEditState()
+ val store = HistoryFragmentStore(initialState)
+
+ store.dispatch(HistoryFragmentAction.RemoveItemForRemoval(newHistoryItem)).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(store.state.mode, HistoryFragmentState.Mode.Editing(setOf(historyItem)))
+ }
+
+ @Test
+ fun startSync() = runTest {
+ val initialState = emptyDefaultState()
+ val store = HistoryFragmentStore(initialState)
+
+ store.dispatch(HistoryFragmentAction.StartSync).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(HistoryFragmentState.Mode.Syncing, store.state.mode)
+ }
+
+ @Test
+ fun finishSync() = runTest {
+ val initialState = HistoryFragmentState(
+ items = listOf(),
+ mode = HistoryFragmentState.Mode.Syncing,
+ pendingDeletionItems = emptySet(),
+ isEmpty = false,
+ isDeletingItems = false,
+ )
+ val store = HistoryFragmentStore(initialState)
+
+ store.dispatch(HistoryFragmentAction.FinishSync).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(HistoryFragmentState.Mode.Normal, store.state.mode)
+ }
+
+ @Test
+ fun changeEmptyState() = runTest {
+ val initialState = emptyDefaultState()
+ val store = HistoryFragmentStore(initialState)
+
+ store.dispatch(HistoryFragmentAction.ChangeEmptyState(true)).join()
+ assertNotSame(initialState, store.state)
+ assertTrue(store.state.isEmpty)
+
+ store.dispatch(HistoryFragmentAction.ChangeEmptyState(false)).join()
+ assertNotSame(initialState, store.state)
+ assertFalse(store.state.isEmpty)
+ }
+
+ @Test
+ fun updatePendingDeletionItems() = runTest {
+ val initialState = emptyDefaultState()
+ val store = HistoryFragmentStore(initialState)
+
+ store.dispatch(HistoryFragmentAction.UpdatePendingDeletionItems(setOf(pendingDeletionItem))).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(setOf(pendingDeletionItem), store.state.pendingDeletionItems)
+
+ store.dispatch(HistoryFragmentAction.UpdatePendingDeletionItems(emptySet())).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(emptySet<PendingDeletionHistory>(), store.state.pendingDeletionItems)
+ }
+
+ @Test
+ fun `GIVEN items have been selected WHEN selected item is clicked THEN item is unselected`() = runTest {
+ val store = HistoryFragmentStore(twoItemEditState())
+
+ store.dispatch(HistoryFragmentAction.HistoryItemClicked(historyItem)).joinBlocking()
+
+ assertEquals(1, store.state.mode.selectedItems.size)
+ assertEquals(newHistoryItem, store.state.mode.selectedItems.first())
+ }
+
+ @Test
+ fun `GIVEN items have been selected WHEN unselected item is clicked THEN item is selected`() {
+ val initialState = oneItemEditState().copy(items = listOf(newHistoryItem))
+ val store = HistoryFragmentStore(initialState)
+
+ store.dispatch(HistoryFragmentAction.HistoryItemClicked(newHistoryItem)).joinBlocking()
+
+ assertEquals(2, store.state.mode.selectedItems.size)
+ assertTrue(store.state.mode.selectedItems.contains(newHistoryItem))
+ }
+
+ @Test
+ fun `GIVEN items have been selected WHEN last selected item is clicked THEN editing mode is exited`() {
+ val store = HistoryFragmentStore(oneItemEditState())
+
+ store.dispatch(HistoryFragmentAction.HistoryItemClicked(historyItem)).joinBlocking()
+
+ assertEquals(0, store.state.mode.selectedItems.size)
+ assertTrue(store.state.mode is HistoryFragmentState.Mode.Normal)
+ }
+
+ @Test
+ fun `GIVEN items have not been selected WHEN item is clicked THEN state is unchanged`() {
+ val store = HistoryFragmentStore(emptyDefaultState())
+
+ store.dispatch(HistoryFragmentAction.HistoryItemClicked(historyItem)).joinBlocking()
+
+ assertEquals(0, store.state.mode.selectedItems.size)
+ assertTrue(store.state.mode is HistoryFragmentState.Mode.Normal)
+ }
+
+ @Test
+ fun `GIVEN mode is syncing WHEN item is clicked THEN state is unchanged`() {
+ val store = HistoryFragmentStore(emptyDefaultState().copy(mode = HistoryFragmentState.Mode.Syncing))
+
+ store.dispatch(HistoryFragmentAction.HistoryItemClicked(historyItem)).joinBlocking()
+
+ assertEquals(0, store.state.mode.selectedItems.size)
+ assertTrue(store.state.mode is HistoryFragmentState.Mode.Syncing)
+ }
+
+ @Test
+ fun `GIVEN mode is syncing WHEN item is long-clicked THEN state is unchanged`() {
+ val store = HistoryFragmentStore(emptyDefaultState().copy(mode = HistoryFragmentState.Mode.Syncing))
+
+ store.dispatch(HistoryFragmentAction.HistoryItemLongClicked(historyItem)).joinBlocking()
+
+ assertEquals(0, store.state.mode.selectedItems.size)
+ assertTrue(store.state.mode is HistoryFragmentState.Mode.Syncing)
+ }
+
+ @Test
+ fun `GIVEN mode is not syncing WHEN item is long-clicked THEN mode becomes editing`() {
+ val store = HistoryFragmentStore(oneItemEditState())
+
+ store.dispatch(HistoryFragmentAction.HistoryItemLongClicked(newHistoryItem)).joinBlocking()
+
+ assertEquals(2, store.state.mode.selectedItems.size)
+ assertTrue(store.state.mode.selectedItems.contains(newHistoryItem))
+ }
+
+ @Test
+ fun `GIVEN mode is not syncing WHEN item is long-clicked THEN item is selected`() {
+ val store = HistoryFragmentStore(emptyDefaultState())
+
+ store.dispatch(HistoryFragmentAction.HistoryItemLongClicked(historyItem)).joinBlocking()
+
+ assertEquals(1, store.state.mode.selectedItems.size)
+ assertTrue(store.state.mode.selectedItems.contains(historyItem))
+ }
+
+ @Test
+ fun `GIVEN mode is editing WHEN back pressed THEN mode becomes normal`() {
+ val store = HistoryFragmentStore(
+ emptyDefaultState().copy(
+ mode = HistoryFragmentState.Mode.Editing(
+ setOf(),
+ ),
+ ),
+ )
+
+ store.dispatch(HistoryFragmentAction.BackPressed).joinBlocking()
+
+ assertEquals(HistoryFragmentState.Mode.Normal, store.state.mode)
+ }
+
+ @Test
+ fun `GIVEN mode is not editing WHEN back pressed THEN does not change`() {
+ val store = HistoryFragmentStore(emptyDefaultState().copy(mode = HistoryFragmentState.Mode.Syncing))
+
+ store.dispatch(HistoryFragmentAction.BackPressed).joinBlocking()
+
+ assertEquals(HistoryFragmentState.Mode.Syncing, store.state.mode)
+ }
+
+ private fun emptyDefaultState(): HistoryFragmentState = HistoryFragmentState(
+ items = listOf(),
+ mode = HistoryFragmentState.Mode.Normal,
+ pendingDeletionItems = emptySet(),
+ isEmpty = false,
+ isDeletingItems = false,
+ )
+
+ private fun oneItemEditState(): HistoryFragmentState = HistoryFragmentState(
+ items = listOf(),
+ mode = HistoryFragmentState.Mode.Editing(setOf(historyItem)),
+ pendingDeletionItems = emptySet(),
+ isEmpty = false,
+ isDeletingItems = false,
+ )
+
+ private fun twoItemEditState(): HistoryFragmentState = HistoryFragmentState(
+ items = listOf(),
+ mode = HistoryFragmentState.Mode.Editing(setOf(historyItem, newHistoryItem)),
+ pendingDeletionItems = emptySet(),
+ isEmpty = false,
+ isDeletingItems = false,
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/HistoryItemTimeGroupTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/HistoryItemTimeGroupTest.kt
new file mode 100644
index 0000000000..8f946692cf
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/HistoryItemTimeGroupTest.kt
@@ -0,0 +1,217 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.history
+
+import android.text.format.DateUtils
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.util.Calendar
+
+class HistoryItemTimeGroupTest {
+
+ @Test
+ fun `WHEN grouping history item with future date THEN item is grouped to today`() {
+ val time = System.currentTimeMillis() + DateUtils.WEEK_IN_MILLIS
+ val history = History.Regular(
+ position = 1,
+ title = "test item",
+ url = "url",
+ visitedAt = time,
+ historyTimeGroup = HistoryItemTimeGroup.timeGroupForTimestamp(time),
+ )
+
+ assertEquals(HistoryItemTimeGroup.Today, history.historyTimeGroup)
+ }
+
+ @Test
+ fun `WHEN grouping history item with today's date THEN item is grouped to today`() {
+ val time = System.currentTimeMillis() + DateUtils.MINUTE_IN_MILLIS
+ val history = History.Regular(
+ position = 1,
+ title = "test item",
+ url = "url",
+ visitedAt = time,
+ historyTimeGroup = HistoryItemTimeGroup.timeGroupForTimestamp(time),
+ )
+
+ assertEquals(HistoryItemTimeGroup.Today, history.historyTimeGroup)
+ }
+
+ @Test
+ fun `WHEN grouping history item with today's midnight date THEN item is grouped to today`() {
+ val calendar = Calendar.getInstance().apply {
+ set(Calendar.HOUR_OF_DAY, 0)
+ set(Calendar.MINUTE, 0)
+ set(Calendar.SECOND, 0)
+ }
+
+ val history = History.Regular(
+ position = 1,
+ title = "test item",
+ url = "url",
+ visitedAt = calendar.timeInMillis,
+ historyTimeGroup = HistoryItemTimeGroup.timeGroupForTimestamp(calendar.timeInMillis),
+ )
+
+ assertEquals(HistoryItemTimeGroup.Today, history.historyTimeGroup)
+ }
+
+ @Test
+ fun `WHEN grouping history item with yesterday's night date THEN item is grouped to yesterday`() {
+ val calendar = Calendar.getInstance().apply {
+ set(Calendar.HOUR_OF_DAY, 0)
+ set(Calendar.MINUTE, 0)
+ set(Calendar.SECOND, 0)
+ }
+
+ val time = calendar.timeInMillis - DateUtils.HOUR_IN_MILLIS
+
+ val history = History.Regular(
+ position = 1,
+ title = "test item",
+ url = "url",
+ visitedAt = time,
+ historyTimeGroup = HistoryItemTimeGroup.timeGroupForTimestamp(time),
+ )
+
+ assertEquals(HistoryItemTimeGroup.Yesterday, history.historyTimeGroup)
+ }
+
+ @Test
+ fun `WHEN grouping history item with 23 hours before midnight date THEN item is grouped to yesterday`() {
+ val calendar = Calendar.getInstance()
+ calendar.set(Calendar.HOUR_OF_DAY, 0)
+ calendar.set(Calendar.MINUTE, 0)
+ calendar.set(Calendar.SECOND, 0)
+
+ val time = calendar.timeInMillis - (DateUtils.HOUR_IN_MILLIS * 23)
+
+ val history = History.Regular(
+ position = 1,
+ title = "test item",
+ url = "url",
+ visitedAt = time,
+ historyTimeGroup = HistoryItemTimeGroup.timeGroupForTimestamp(time),
+ )
+
+ assertEquals(HistoryItemTimeGroup.Yesterday, history.historyTimeGroup)
+ }
+
+ @Test
+ fun `WHEN grouping history item with 25 hours before midnight date THEN item is grouped to this week`() {
+ val calendar = Calendar.getInstance().apply {
+ set(Calendar.HOUR_OF_DAY, 0)
+ set(Calendar.MINUTE, 0)
+ set(Calendar.SECOND, 0)
+ }
+
+ val time = calendar.timeInMillis - (DateUtils.HOUR_IN_MILLIS * 25)
+ val history = History.Regular(
+ position = 1,
+ title = "test item",
+ url = "url",
+ visitedAt = time,
+ historyTimeGroup = HistoryItemTimeGroup.timeGroupForTimestamp(time),
+ )
+
+ assertEquals(HistoryItemTimeGroup.ThisWeek, history.historyTimeGroup)
+ }
+
+ @Test
+ fun `WHEN grouping history item with 3 days ago date THEN item is grouped to this week`() {
+ val time = System.currentTimeMillis() - (DateUtils.DAY_IN_MILLIS * 3)
+ val history = History.Regular(
+ position = 1,
+ title = "test item",
+ url = "url",
+ visitedAt = time,
+ historyTimeGroup = HistoryItemTimeGroup.timeGroupForTimestamp(time),
+ )
+
+ assertEquals(HistoryItemTimeGroup.ThisWeek, history.historyTimeGroup)
+ }
+
+ @Test
+ fun `WHEN grouping history item with 6 days ago date THEN item is grouped to this week`() {
+ val time = System.currentTimeMillis() - (DateUtils.DAY_IN_MILLIS * 6)
+ val history = History.Regular(
+ position = 1,
+ title = "test item",
+ url = "url",
+ visitedAt = time,
+ historyTimeGroup = HistoryItemTimeGroup.timeGroupForTimestamp(time),
+ )
+
+ assertEquals(HistoryItemTimeGroup.ThisWeek, history.historyTimeGroup)
+ }
+
+ @Test
+ fun `WHEN grouping history item with 8 days ago date THEN item is grouped to this month`() {
+ val time = System.currentTimeMillis() - (DateUtils.DAY_IN_MILLIS * 8)
+ val history = History.Regular(
+ position = 1,
+ title = "test item",
+ url = "url",
+ visitedAt = time,
+ historyTimeGroup = HistoryItemTimeGroup.timeGroupForTimestamp(time),
+ )
+
+ assertEquals(HistoryItemTimeGroup.ThisMonth, history.historyTimeGroup)
+ }
+
+ @Test
+ fun `WHEN grouping history item with 29 days ago date THEN item is grouped to this month`() {
+ val time = System.currentTimeMillis() - (DateUtils.DAY_IN_MILLIS * 29)
+ val history = History.Regular(
+ position = 1,
+ title = "test item",
+ url = "url",
+ visitedAt = time,
+ historyTimeGroup = HistoryItemTimeGroup.timeGroupForTimestamp(time),
+ )
+
+ assertEquals(HistoryItemTimeGroup.ThisMonth, history.historyTimeGroup)
+ }
+
+ @Test
+ fun `WHEN grouping history item with 31 days ago date THEN item is grouped to older`() {
+ val time = System.currentTimeMillis() - (DateUtils.DAY_IN_MILLIS * 31)
+ val history = History.Regular(
+ position = 1,
+ title = "test item",
+ url = "url",
+ visitedAt = time,
+ historyTimeGroup = HistoryItemTimeGroup.timeGroupForTimestamp(time),
+ )
+
+ assertEquals(HistoryItemTimeGroup.Older, history.historyTimeGroup)
+ }
+
+ @Test
+ fun `WHEN grouping history item with zero date THEN item is grouped to older`() {
+ val history = History.Regular(
+ position = 1,
+ title = "test item",
+ url = "url",
+ visitedAt = 0,
+ historyTimeGroup = HistoryItemTimeGroup.timeGroupForTimestamp(0),
+ )
+
+ assertEquals(HistoryItemTimeGroup.Older, history.historyTimeGroup)
+ }
+
+ @Test
+ fun `WHEN grouping history item with negative date THEN item is grouped to older`() {
+ val history = History.Regular(
+ position = 1,
+ title = "test item",
+ url = "url",
+ visitedAt = -100,
+ historyTimeGroup = HistoryItemTimeGroup.timeGroupForTimestamp(-100),
+ )
+
+ assertEquals(HistoryItemTimeGroup.Older, history.historyTimeGroup)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/RemoveTimeFrameTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/RemoveTimeFrameTest.kt
new file mode 100644
index 0000000000..639b753ef0
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/RemoveTimeFrameTest.kt
@@ -0,0 +1,58 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.history
+
+import org.junit.Assert
+import org.junit.Test
+import java.util.Calendar
+import java.util.concurrent.TimeUnit
+
+class RemoveTimeFrameTest {
+
+ @Test
+ fun `WHEN LastHour is calculated THEN first timeStamp is one hour ago`() {
+ val lastHourRange = RemoveTimeFrame.LastHour.toLongRange()
+ val nowMillis = Calendar.getInstance().timeInMillis
+ val millisDif = nowMillis - lastHourRange.first
+ val hourDif = TimeUnit.HOURS.convert(millisDif, TimeUnit.MILLISECONDS)
+ Assert.assertEquals(1, hourDif)
+ }
+
+ @Test
+ fun `WHEN LastHour is calculated THEN second timeStamp is equal or greater than now`() {
+ val lastHourRange = RemoveTimeFrame.LastHour.toLongRange()
+ val nowMillis = Calendar.getInstance().timeInMillis
+ Assert.assertTrue(nowMillis <= lastHourRange.last)
+ }
+
+ @Test
+ fun `WHEN TodayAndYesterday is calculated THEN first timeStamp is one day ago`() {
+ val lastHourRange = RemoveTimeFrame.TodayAndYesterday.toLongRange()
+ val nowMillis = Calendar.getInstance().timeInMillis
+ val millisDif = nowMillis - lastHourRange.first
+ val daysDif = TimeUnit.DAYS.convert(millisDif, TimeUnit.MILLISECONDS)
+ Assert.assertEquals(1, daysDif)
+ }
+
+ @Test
+ fun `WHEN TodayAndYesterday is calculated THEN first timeStamp is the start of the previous day`() {
+ val todayAndYesterdayRange = RemoveTimeFrame.TodayAndYesterday.toLongRange()
+ val yesterdayStartMillis = Calendar.getInstance().apply {
+ add(Calendar.DAY_OF_YEAR, -1)
+ set(Calendar.HOUR_OF_DAY, 0)
+ set(Calendar.MINUTE, 0)
+ set(Calendar.SECOND, 0)
+ set(Calendar.MILLISECOND, 0)
+ }.timeInMillis
+ Assert.assertEquals(yesterdayStartMillis, todayAndYesterdayRange.first)
+ }
+
+ @Test
+ fun `WHEN TodayAndYesterday is calculated THEN second timeStamp is equal or greater than now`() {
+ val lastHourRange = RemoveTimeFrame.TodayAndYesterday.toLongRange()
+ val nowMillis = Calendar.getInstance().timeInMillis
+ Assert.assertTrue(nowMillis <= lastHourRange.last)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/state/HistoryNavigationMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/state/HistoryNavigationMiddlewareTest.kt
new file mode 100644
index 0000000000..01a1f0d4bc
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/state/HistoryNavigationMiddlewareTest.kt
@@ -0,0 +1,191 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.history.state
+
+import androidx.navigation.NavController
+import androidx.navigation.NavOptions
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import mozilla.components.support.test.any
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.whenever
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mockito.verify
+import org.mozilla.fenix.R
+import org.mozilla.fenix.ext.navigateSafe
+import org.mozilla.fenix.library.history.History
+import org.mozilla.fenix.library.history.HistoryFragmentAction
+import org.mozilla.fenix.library.history.HistoryFragmentDirections
+import org.mozilla.fenix.library.history.HistoryFragmentState
+import org.mozilla.fenix.library.history.HistoryFragmentStore
+import org.mozilla.fenix.library.history.HistoryItemTimeGroup
+
+class HistoryNavigationMiddlewareTest {
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Test
+ fun `WHEN regular history item clicked THEN item is opened in browser`() = runTest {
+ var openedInBrowser = false
+ val url = "url"
+ val history = History.Regular(0, "title", url, 0, HistoryItemTimeGroup.timeGroupForTimestamp(0))
+ val middleware = HistoryNavigationMiddleware(
+ navController = mock(),
+ openToBrowser = { item ->
+ if (item.url == url) {
+ openedInBrowser = true
+ }
+ },
+ onBackPressed = { },
+ scope = this,
+ )
+ val store =
+ HistoryFragmentStore(HistoryFragmentState.initial, middleware = listOf(middleware))
+
+ store.dispatch(HistoryFragmentAction.HistoryItemClicked(history)).joinBlocking()
+ advanceUntilIdle()
+
+ assertTrue(openedInBrowser)
+ }
+
+ @Test
+ fun `GIVEN selected items WHEN the last selected item is clicked THEN last item is not opened`() = runTest {
+ var openedInBrowser = false
+ val url = "url"
+ val history = History.Regular(0, "title", url, 0, HistoryItemTimeGroup.timeGroupForTimestamp(0))
+ val middleware = HistoryNavigationMiddleware(
+ navController = mock(),
+ openToBrowser = { item ->
+ if (item.url == url) {
+ openedInBrowser = true
+ }
+ },
+ onBackPressed = { },
+ scope = this,
+ )
+ val state = HistoryFragmentState.initial.copy(
+ mode = HistoryFragmentState.Mode.Editing(selectedItems = setOf(history)),
+ )
+ val store =
+ HistoryFragmentStore(initialState = state, middleware = listOf(middleware))
+
+ store.dispatch(HistoryFragmentAction.HistoryItemClicked(history)).joinBlocking()
+ advanceUntilIdle()
+
+ assertFalse(openedInBrowser)
+ }
+
+ @Test
+ fun `WHEN group history item clicked THEN navigate to history metadata fragment`() = runTest {
+ val title = "title"
+ val history = History.Group(0, title, 0, HistoryItemTimeGroup.timeGroupForTimestamp(0), listOf())
+ val navController = mock<NavController>()
+ whenever(navController.navigate(directions = any(), navOptions = any())).thenAnswer { }
+ val middleware = HistoryNavigationMiddleware(
+ navController = navController,
+ openToBrowser = { },
+ onBackPressed = { },
+ scope = this,
+ )
+ val store =
+ HistoryFragmentStore(HistoryFragmentState.initial, middleware = listOf(middleware))
+
+ store.dispatch(HistoryFragmentAction.HistoryItemClicked(history)).joinBlocking()
+ advanceUntilIdle()
+
+ verify(navController).navigate(
+ directions = any(),
+ navOptions = any(),
+ )
+ }
+
+ @Test
+ fun `WHEN recently closed is requested to be entered THEN nav controller navigates to it`() = runTest {
+ val navController = mock<NavController>()
+ val middleware = HistoryNavigationMiddleware(
+ navController = navController,
+ openToBrowser = { },
+ onBackPressed = { },
+ scope = this,
+ )
+ val store =
+ HistoryFragmentStore(HistoryFragmentState.initial, middleware = listOf(middleware))
+
+ store.dispatch(HistoryFragmentAction.EnterRecentlyClosed).joinBlocking()
+ advanceUntilIdle()
+
+ verify(navController).navigate(
+ HistoryFragmentDirections.actionGlobalRecentlyClosed(),
+ NavOptions.Builder().setPopUpTo(R.id.recentlyClosedFragment, true).build(),
+ )
+ }
+
+ @Test
+ fun `GIVEN mode is editing WHEN back pressed THEN no navigation happens`() = runTest {
+ var onBackPressed = false
+ val middleware = HistoryNavigationMiddleware(
+ navController = mock(),
+ openToBrowser = { },
+ onBackPressed = { onBackPressed = true },
+ scope = this,
+ )
+ val store =
+ HistoryFragmentStore(
+ HistoryFragmentState.initial.copy(
+ mode = HistoryFragmentState.Mode.Editing(
+ setOf(),
+ ),
+ ),
+ middleware = listOf(middleware),
+ )
+
+ store.dispatch(HistoryFragmentAction.BackPressed).joinBlocking()
+ advanceUntilIdle()
+
+ assertFalse(onBackPressed)
+ }
+
+ @Test
+ fun `GIVEN mode is not editing WHEN back pressed THEN onBackPressed callback invoked`() = runTest {
+ var onBackPressed = false
+ val middleware = HistoryNavigationMiddleware(
+ navController = mock(),
+ openToBrowser = { },
+ onBackPressed = { onBackPressed = true },
+ scope = this,
+ )
+ val store =
+ HistoryFragmentStore(HistoryFragmentState.initial, middleware = listOf(middleware))
+
+ store.dispatch(HistoryFragmentAction.BackPressed).joinBlocking()
+ advanceUntilIdle()
+
+ assertTrue(onBackPressed)
+ }
+
+ @Test
+ fun `WHEN search is clicked THEN search navigated to`() = runTest {
+ val navController = mock<NavController>()
+ val middleware = HistoryNavigationMiddleware(
+ navController = navController,
+ openToBrowser = { },
+ onBackPressed = { },
+ scope = this,
+ )
+
+ val store =
+ HistoryFragmentStore(HistoryFragmentState.initial, middleware = listOf(middleware))
+
+ store.dispatch(HistoryFragmentAction.SearchClicked).joinBlocking()
+ advanceUntilIdle()
+
+ verify(navController).navigateSafe(R.id.historyFragment, HistoryFragmentDirections.actionGlobalSearchDialog(null))
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/state/HistoryStorageMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/state/HistoryStorageMiddlewareTest.kt
new file mode 100644
index 0000000000..2b0d6e620d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/state/HistoryStorageMiddlewareTest.kt
@@ -0,0 +1,228 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.history.state
+
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.advanceUntilIdle
+import mozilla.components.browser.state.action.BrowserAction
+import mozilla.components.browser.state.action.EngineAction
+import mozilla.components.browser.state.action.HistoryMetadataAction
+import mozilla.components.browser.state.action.RecentlyClosedAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.browser.storage.sync.PlacesHistoryStorage
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.middleware.CaptureActionsMiddleware
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Ignore
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mockito.anyLong
+import org.mockito.Mockito.verify
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppAction
+import org.mozilla.fenix.components.history.DefaultPagedHistoryProvider
+import org.mozilla.fenix.library.history.History
+import org.mozilla.fenix.library.history.HistoryFragmentAction
+import org.mozilla.fenix.library.history.HistoryFragmentState
+import org.mozilla.fenix.library.history.HistoryFragmentStore
+import org.mozilla.fenix.library.history.HistoryItemTimeGroup
+import org.mozilla.fenix.library.history.RemoveTimeFrame
+import org.mozilla.fenix.library.history.toPendingDeletionHistory
+
+class HistoryStorageMiddlewareTest {
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ private val appStore = mock<AppStore>()
+ private lateinit var captureBrowserActions: CaptureActionsMiddleware<BrowserState, BrowserAction>
+ private lateinit var browserStore: BrowserStore
+ private val provider = mock<DefaultPagedHistoryProvider>()
+ private lateinit var storage: PlacesHistoryStorage
+
+ @Before
+ fun setup() {
+ storage = mock()
+ captureBrowserActions = CaptureActionsMiddleware()
+ browserStore = BrowserStore(middleware = listOf(captureBrowserActions))
+ }
+
+ @Test
+ fun `WHEN items are deleted THEN action to add to pending deletion is dispatched and snackbar is shown`() = runTestOnMain {
+ var snackbarCalled = false
+ val history = setOf(History.Regular(0, "title", "url", 0, HistoryItemTimeGroup.timeGroupForTimestamp(0)))
+ val middleware = HistoryStorageMiddleware(
+ appStore = appStore,
+ browserStore = browserStore,
+ historyProvider = provider,
+ historyStorage = storage,
+ undoDeleteSnackbar = { _, _, _ -> snackbarCalled = true },
+ onTimeFrameDeleted = { },
+ scope = this,
+ )
+ val store = HistoryFragmentStore(
+ initialState = HistoryFragmentState.initial,
+ middleware = listOf(middleware),
+ )
+
+ store.dispatch(HistoryFragmentAction.DeleteItems(history)).joinBlocking()
+ store.waitUntilIdle()
+
+ assertTrue(snackbarCalled)
+ verify(appStore).dispatch(AppAction.AddPendingDeletionSet(history.toPendingDeletionHistory()))
+ }
+
+ @Test
+ fun `WHEN items are deleted THEN undo dispatches action to remove them from pending deletion state`() = runTestOnMain {
+ var snackbarCalled = false
+ val history = setOf(History.Regular(0, "title", "url", 0, HistoryItemTimeGroup.timeGroupForTimestamp(0)))
+ val middleware = HistoryStorageMiddleware(
+ appStore = appStore,
+ browserStore = browserStore,
+ historyProvider = provider,
+ historyStorage = storage,
+ undoDeleteSnackbar = { _, undo, _ ->
+ runBlocking { undo(history) }
+ snackbarCalled = true
+ },
+ onTimeFrameDeleted = { },
+ scope = this,
+ )
+ val store = HistoryFragmentStore(
+ initialState = HistoryFragmentState.initial,
+ middleware = listOf(middleware),
+ )
+
+ store.dispatch(HistoryFragmentAction.DeleteItems(history)).joinBlocking()
+ store.waitUntilIdle()
+
+ assertTrue(snackbarCalled)
+ verify(appStore).dispatch(AppAction.AddPendingDeletionSet(history.toPendingDeletionHistory()))
+ verify(appStore).dispatch(AppAction.UndoPendingDeletionSet(history.toPendingDeletionHistory()))
+ }
+
+ @Test
+ fun `WHEN regular items are deleted and not undone THEN items are removed from storage`() = runTestOnMain {
+ var snackbarCalled = false
+ val history = setOf(History.Regular(0, "title", "url", 0, HistoryItemTimeGroup.timeGroupForTimestamp(0)))
+ val middleware = HistoryStorageMiddleware(
+ appStore = appStore,
+ browserStore = browserStore,
+ historyProvider = provider,
+ historyStorage = storage,
+ undoDeleteSnackbar = { _, _, delete ->
+ runBlocking { delete(history) }
+ snackbarCalled = true
+ },
+ onTimeFrameDeleted = { },
+ scope = this,
+ )
+ val store = HistoryFragmentStore(
+ initialState = HistoryFragmentState.initial,
+ middleware = listOf(middleware),
+ )
+
+ store.dispatch(HistoryFragmentAction.DeleteItems(history)).joinBlocking()
+ store.waitUntilIdle()
+
+ assertTrue(snackbarCalled)
+ verify(storage).deleteVisitsFor(history.first().url)
+ }
+
+ @Test
+ fun `WHEN group items are deleted and not undone THEN items are removed from provider and browser store updated`() = runTestOnMain {
+ var snackbarCalled = false
+ val history = setOf(History.Group(0, "title", 0, HistoryItemTimeGroup.timeGroupForTimestamp(0), listOf()))
+ val middleware = HistoryStorageMiddleware(
+ appStore = appStore,
+ browserStore = browserStore,
+ historyProvider = provider,
+ historyStorage = storage,
+ undoDeleteSnackbar = { _, _, delete ->
+ runBlocking { delete(history) }
+ snackbarCalled = true
+ },
+ onTimeFrameDeleted = { },
+ scope = this,
+ )
+ val store = HistoryFragmentStore(
+ initialState = HistoryFragmentState.initial,
+ middleware = listOf(middleware),
+ )
+
+ store.dispatch(HistoryFragmentAction.DeleteItems(history)).joinBlocking()
+ store.waitUntilIdle()
+ browserStore.waitUntilIdle()
+
+ assertTrue(snackbarCalled)
+ captureBrowserActions.assertFirstAction(HistoryMetadataAction.DisbandSearchGroupAction::class)
+ verify(provider).deleteMetadataSearchGroup(history.first())
+ }
+
+ @Test
+ fun `WHEN a null time frame is deleted THEN browser store is informed, storage deletes everything, and callback is invoked`() = runTestOnMain {
+ var callbackInvoked = false
+ val middleware = HistoryStorageMiddleware(
+ appStore = appStore,
+ browserStore = browserStore,
+ historyProvider = provider,
+ historyStorage = storage,
+ undoDeleteSnackbar = mock(),
+ onTimeFrameDeleted = { callbackInvoked = true },
+ scope = this,
+ )
+ val store = HistoryFragmentStore(
+ initialState = HistoryFragmentState.initial,
+ middleware = listOf(middleware),
+ )
+
+ store.dispatch(HistoryFragmentAction.DeleteTimeRange(null)).joinBlocking()
+ store.waitUntilIdle()
+ browserStore.waitUntilIdle()
+
+ assertTrue(callbackInvoked)
+ assertEquals(HistoryFragmentState.Mode.Normal, store.state.mode)
+ captureBrowserActions.assertFirstAction(RecentlyClosedAction.RemoveAllClosedTabAction::class)
+ captureBrowserActions.assertLastAction(EngineAction.PurgeHistoryAction::class) {}
+ verify(storage).deleteEverything()
+ }
+
+ @Ignore("Intermittent failure; see Bug 1848436.")
+ @Test
+ fun `WHEN a specified time frame is deleted THEN browser store is informed, storage deletes time frame, and callback is invoked`() = runTestOnMain {
+ var callbackInvoked = false
+ val middleware = HistoryStorageMiddleware(
+ appStore = appStore,
+ browserStore = browserStore,
+ historyProvider = provider,
+ historyStorage = storage,
+ undoDeleteSnackbar = mock(),
+ onTimeFrameDeleted = { callbackInvoked = true },
+ scope = this,
+ )
+ val store = HistoryFragmentStore(
+ initialState = HistoryFragmentState.initial,
+ middleware = listOf(middleware),
+ )
+
+ store.dispatch(HistoryFragmentAction.DeleteTimeRange(RemoveTimeFrame.LastHour)).joinBlocking()
+ store.waitUntilIdle()
+ browserStore.waitUntilIdle()
+ this.advanceUntilIdle()
+
+ assertTrue(callbackInvoked)
+ assertFalse(store.state.isDeletingItems)
+ captureBrowserActions.assertFirstAction(RecentlyClosedAction.RemoveAllClosedTabAction::class)
+ captureBrowserActions.assertLastAction(EngineAction.PurgeHistoryAction::class) {}
+ verify(storage).deleteVisitsBetween(anyLong(), anyLong())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/state/HistorySyncMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/state/HistorySyncMiddlewareTest.kt
new file mode 100644
index 0000000000..b185136e99
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/state/HistorySyncMiddlewareTest.kt
@@ -0,0 +1,53 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.history.state
+
+import mozilla.components.service.fxa.SyncEngine
+import mozilla.components.service.fxa.manager.FxaAccountManager
+import mozilla.components.service.fxa.sync.SyncReason
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mockito.verify
+import org.mozilla.fenix.library.history.HistoryFragmentAction
+import org.mozilla.fenix.library.history.HistoryFragmentState
+import org.mozilla.fenix.library.history.HistoryFragmentStore
+
+class HistorySyncMiddlewareTest {
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Test
+ fun `WHEN sync is started THEN account manager handles sync, the view is refreshed, and the sync is finished`() = runTestOnMain {
+ var viewIsRefreshed = false
+ val accountManager = mock<FxaAccountManager>()
+ val middleware = HistorySyncMiddleware(
+ accountManager = accountManager,
+ refreshView = { viewIsRefreshed = true },
+ scope = this,
+ )
+ val store = HistoryFragmentStore(
+ initialState = HistoryFragmentState.initial,
+ middleware = listOf(middleware),
+ )
+
+ store.dispatch(HistoryFragmentAction.StartSync).joinBlocking()
+ store.waitUntilIdle()
+
+ assertTrue(viewIsRefreshed)
+ assertEquals(HistoryFragmentState.Mode.Normal, store.state.mode)
+ verify(accountManager).syncNow(
+ reason = SyncReason.User,
+ debounce = true,
+ customEngineSubset = listOf(SyncEngine.History),
+ )
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/state/HistoryTelemetryMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/state/HistoryTelemetryMiddlewareTest.kt
new file mode 100644
index 0000000000..d4f5a02a1a
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/state/HistoryTelemetryMiddlewareTest.kt
@@ -0,0 +1,124 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.history.state
+
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.Events
+import org.mozilla.fenix.library.history.History
+import org.mozilla.fenix.library.history.HistoryFragmentAction
+import org.mozilla.fenix.library.history.HistoryFragmentState
+import org.mozilla.fenix.library.history.HistoryFragmentStore
+import org.mozilla.fenix.library.history.HistoryItemTimeGroup
+import org.mozilla.fenix.library.history.RemoveTimeFrame
+import org.robolectric.RobolectricTestRunner
+import org.mozilla.fenix.GleanMetrics.History as GleanHistory
+
+@RunWith(RobolectricTestRunner::class)
+class HistoryTelemetryMiddlewareTest {
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ private val middleware = HistoryTelemetryMiddleware(isInPrivateMode = false)
+
+ @Test
+ fun `GIVEN no items selected WHEN regular history item clicked THEN telemetry recorded`() {
+ val history = History.Regular(0, "title", "url", 0, HistoryItemTimeGroup.timeGroupForTimestamp(0))
+ val store = HistoryFragmentStore(
+ initialState = HistoryFragmentState.initial,
+ middleware = listOf(middleware),
+ )
+
+ store.dispatch(HistoryFragmentAction.HistoryItemClicked(history)).joinBlocking()
+
+ assertNotNull(GleanHistory.openedItem.testGetValue())
+ }
+
+ @Test
+ fun `GIVEN items selected WHEN regular history item clicked THEN no telemetry recorded`() {
+ val history = History.Regular(0, "title", "url", 0, HistoryItemTimeGroup.timeGroupForTimestamp(0))
+ val state = HistoryFragmentState.initial.copy(
+ mode = HistoryFragmentState.Mode.Editing(selectedItems = setOf(history)),
+ )
+ val store = HistoryFragmentStore(
+ initialState = state,
+ middleware = listOf(middleware),
+ )
+
+ store.dispatch(HistoryFragmentAction.HistoryItemClicked(history)).joinBlocking()
+
+ assertNull(GleanHistory.openedItem.testGetValue())
+ }
+
+ @Test
+ fun `WHEN group history item clicked THEN record telemetry`() {
+ val history = History.Group(0, "title", 0, HistoryItemTimeGroup.timeGroupForTimestamp(0), listOf())
+ val store =
+ HistoryFragmentStore(HistoryFragmentState.initial, middleware = listOf(middleware))
+
+ store.dispatch(HistoryFragmentAction.HistoryItemClicked(history)).joinBlocking()
+
+ assertNotNull(GleanHistory.searchTermGroupTapped.testGetValue())
+ }
+
+ @Test
+ fun `WHEN history items deleted THEN record telemetry`() {
+ val history = History.Regular(0, "title", "url", 0, HistoryItemTimeGroup.timeGroupForTimestamp(0))
+ val store =
+ HistoryFragmentStore(HistoryFragmentState.initial, middleware = listOf(middleware))
+
+ store.dispatch(HistoryFragmentAction.DeleteItems(setOf(history))).joinBlocking()
+
+ assertNotNull(GleanHistory.removed.testGetValue())
+ }
+
+ @Test
+ fun `WHEN history time range of last hour deleted THEN record telemetry`() {
+ val store =
+ HistoryFragmentStore(HistoryFragmentState.initial, middleware = listOf(middleware))
+
+ store.dispatch(HistoryFragmentAction.DeleteTimeRange(RemoveTimeFrame.LastHour)).joinBlocking()
+
+ assertNotNull(GleanHistory.removedLastHour.testGetValue())
+ }
+
+ @Test
+ fun `WHEN history time range of today and yesterday deleted THEN record telemetry`() {
+ val store =
+ HistoryFragmentStore(HistoryFragmentState.initial, middleware = listOf(middleware))
+
+ store.dispatch(HistoryFragmentAction.DeleteTimeRange(RemoveTimeFrame.TodayAndYesterday)).joinBlocking()
+
+ assertNotNull(GleanHistory.removedTodayAndYesterday.testGetValue())
+ }
+
+ @Test
+ fun `WHEN history time range deleted with no range specified THEN record telemetry`() {
+ val store =
+ HistoryFragmentStore(HistoryFragmentState.initial, middleware = listOf(middleware))
+
+ store.dispatch(HistoryFragmentAction.DeleteTimeRange(null)).joinBlocking()
+
+ assertNotNull(GleanHistory.removedAll.testGetValue())
+ }
+
+ @Test
+ fun `GIVEN recently closed is requested to be entered THEN telemetry recorded`() {
+ val store = HistoryFragmentStore(
+ initialState = HistoryFragmentState.initial,
+ middleware = listOf(middleware),
+ )
+
+ store.dispatch(HistoryFragmentAction.EnterRecentlyClosed).joinBlocking()
+
+ assertNotNull(Events.recentlyClosedTabsOpened.testGetValue())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/state/bindings/MenuBindingTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/state/bindings/MenuBindingTest.kt
new file mode 100644
index 0000000000..4ea81c20fc
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/state/bindings/MenuBindingTest.kt
@@ -0,0 +1,34 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.history.state.bindings
+
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.library.history.HistoryFragmentAction
+import org.mozilla.fenix.library.history.HistoryFragmentState
+import org.mozilla.fenix.library.history.HistoryFragmentStore
+
+class MenuBindingTest {
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Test
+ fun `WHEN the mode is updated THEN the menu is invalidated`() {
+ var menuInvalidated = false
+ val store = HistoryFragmentStore(HistoryFragmentState.initial.copy(mode = HistoryFragmentState.Mode.Syncing))
+ val binding = MenuBinding(
+ store = store,
+ invalidateOptionsMenu = { menuInvalidated = true },
+ )
+
+ binding.start()
+ store.dispatch(HistoryFragmentAction.FinishSync).joinBlocking()
+
+ assertTrue(menuInvalidated)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/historymetadata/HistoryMetadataGroupFragmentStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/historymetadata/HistoryMetadataGroupFragmentStoreTest.kt
new file mode 100644
index 0000000000..1664ab7b04
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/historymetadata/HistoryMetadataGroupFragmentStoreTest.kt
@@ -0,0 +1,134 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.historymetadata
+
+import kotlinx.coroutines.test.runTest
+import mozilla.components.concept.storage.HistoryMetadataKey
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.library.history.History
+import org.mozilla.fenix.library.history.HistoryItemTimeGroup
+import org.mozilla.fenix.library.history.PendingDeletionHistory
+import org.mozilla.fenix.library.history.toPendingDeletionHistory
+
+class HistoryMetadataGroupFragmentStoreTest {
+
+ private lateinit var state: HistoryMetadataGroupFragmentState
+ private lateinit var store: HistoryMetadataGroupFragmentStore
+
+ private val mozillaHistoryMetadataItem = History.Metadata(
+ position = 1,
+ title = "Mozilla",
+ url = "mozilla.org",
+ visitedAt = 0,
+ historyTimeGroup = HistoryItemTimeGroup.timeGroupForTimestamp(0),
+ totalViewTime = 0,
+ historyMetadataKey = HistoryMetadataKey("http://www.mozilla.com", "mozilla", null),
+ )
+ private val firefoxHistoryMetadataItem = History.Metadata(
+ position = 1,
+ title = "Firefox",
+ url = "firefox.com",
+ visitedAt = 0,
+ historyTimeGroup = HistoryItemTimeGroup.timeGroupForTimestamp(0),
+ totalViewTime = 0,
+ historyMetadataKey = HistoryMetadataKey("http://www.firefox.com", "mozilla", null),
+ )
+ private val pendingDeletionItem = mozillaHistoryMetadataItem.toPendingDeletionHistory()
+
+ @Before
+ fun setup() {
+ state = HistoryMetadataGroupFragmentState(
+ items = emptyList(),
+ pendingDeletionItems = emptySet(),
+ isEmpty = true,
+ )
+ store = HistoryMetadataGroupFragmentStore(state)
+ }
+
+ @Test
+ fun `Test updating the items in HistoryMetadataGroupFragmentStore`() = runTest {
+ assertEquals(0, store.state.items.size)
+
+ val items = listOf(mozillaHistoryMetadataItem, firefoxHistoryMetadataItem)
+ store.dispatch(HistoryMetadataGroupFragmentAction.UpdateHistoryItems(items)).join()
+
+ assertEquals(items, store.state.items)
+ }
+
+ @Test
+ fun `Test selecting and deselecting an item in HistoryMetadataGroupFragmentStore`() = runTest {
+ val items = listOf(mozillaHistoryMetadataItem, firefoxHistoryMetadataItem)
+
+ store.dispatch(HistoryMetadataGroupFragmentAction.UpdateHistoryItems(items)).join()
+
+ assertFalse(store.state.items[0].selected)
+ assertFalse(store.state.items[1].selected)
+
+ store.dispatch(HistoryMetadataGroupFragmentAction.Select(mozillaHistoryMetadataItem)).join()
+
+ assertTrue(store.state.items[0].selected)
+ assertFalse(store.state.items[1].selected)
+
+ store.dispatch(HistoryMetadataGroupFragmentAction.Deselect(store.state.items[0])).join()
+
+ assertFalse(store.state.items[0].selected)
+ assertFalse(store.state.items[1].selected)
+ }
+
+ @Test
+ fun `Test deselecting all items in HistoryMetadataGroupFragmentStore`() = runTest {
+ val items = listOf(mozillaHistoryMetadataItem, firefoxHistoryMetadataItem)
+
+ store.dispatch(HistoryMetadataGroupFragmentAction.UpdateHistoryItems(items)).join()
+ store.dispatch(HistoryMetadataGroupFragmentAction.Select(mozillaHistoryMetadataItem)).join()
+ store.dispatch(HistoryMetadataGroupFragmentAction.DeselectAll).join()
+
+ assertFalse(store.state.items[0].selected)
+ assertFalse(store.state.items[1].selected)
+ }
+
+ @Test
+ fun `Test deleting an item in HistoryMetadataGroupFragmentStore`() = runTest {
+ val items = listOf(mozillaHistoryMetadataItem, firefoxHistoryMetadataItem)
+
+ store.dispatch(HistoryMetadataGroupFragmentAction.UpdateHistoryItems(items)).join()
+ store.dispatch(HistoryMetadataGroupFragmentAction.Delete(mozillaHistoryMetadataItem)).join()
+
+ assertEquals(1, store.state.items.size)
+ assertEquals(firefoxHistoryMetadataItem, store.state.items.first())
+ }
+
+ @Test
+ fun `Test deleting all items in HistoryMetadataGroupFragmentStore`() = runTest {
+ val items = listOf(mozillaHistoryMetadataItem, firefoxHistoryMetadataItem)
+
+ store.dispatch(HistoryMetadataGroupFragmentAction.UpdateHistoryItems(items)).join()
+ store.dispatch(HistoryMetadataGroupFragmentAction.DeleteAll).join()
+
+ assertEquals(0, store.state.items.size)
+ }
+
+ @Test
+ fun `Test changing the empty state of HistoryMetadataGroupFragmentStore`() = runTest {
+ store.dispatch(HistoryMetadataGroupFragmentAction.ChangeEmptyState(false)).join()
+ assertFalse(store.state.isEmpty)
+
+ store.dispatch(HistoryMetadataGroupFragmentAction.ChangeEmptyState(true)).join()
+ assertTrue(store.state.isEmpty)
+ }
+
+ @Test
+ fun `Test updating pending deletion items in HistoryMetadataGroupFragmentStore`() = runTest {
+ store.dispatch(HistoryMetadataGroupFragmentAction.UpdatePendingDeletionItems(setOf(pendingDeletionItem))).join()
+ assertEquals(setOf(pendingDeletionItem), store.state.pendingDeletionItems)
+
+ store.dispatch(HistoryMetadataGroupFragmentAction.UpdatePendingDeletionItems(setOf())).join()
+ assertEquals(emptySet<PendingDeletionHistory>(), store.state.pendingDeletionItems)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/historymetadata/controller/HistoryMetadataGroupControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/historymetadata/controller/HistoryMetadataGroupControllerTest.kt
new file mode 100644
index 0000000000..bcdcae3f5f
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/historymetadata/controller/HistoryMetadataGroupControllerTest.kt
@@ -0,0 +1,340 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.historymetadata.controller
+
+import android.content.Context
+import androidx.navigation.NavController
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.launch
+import mozilla.components.browser.state.action.HistoryMetadataAction
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.browser.storage.sync.PlacesHistoryStorage
+import mozilla.components.concept.engine.prompt.ShareData
+import mozilla.components.concept.storage.HistoryMetadataKey
+import mozilla.components.feature.tabs.TabsUseCases
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.R
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.directionsEq
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.library.history.History
+import org.mozilla.fenix.library.history.HistoryItemTimeGroup
+import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentAction
+import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentDirections
+import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentStore
+import org.mozilla.fenix.GleanMetrics.History as GleanHistory
+
+@RunWith(FenixRobolectricTestRunner::class)
+class HistoryMetadataGroupControllerTest {
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val scope = coroutinesTestRule.scope
+
+ private val activity: HomeActivity = mockk(relaxed = true)
+ private val context: Context = mockk(relaxed = true)
+ private val appStore: AppStore = mockk(relaxed = true)
+ private val store: HistoryMetadataGroupFragmentStore = mockk(relaxed = true)
+ private val browserStore: BrowserStore = mockk(relaxed = true)
+ private val selectOrAddUseCase: TabsUseCases.SelectOrAddUseCase = mockk(relaxed = true)
+ private val navController: NavController = mockk(relaxed = true)
+ private val historyStorage: PlacesHistoryStorage = mockk(relaxed = true)
+
+ private val searchTerm = "mozilla"
+ private val historyMetadataKey = HistoryMetadataKey("http://www.mozilla.com", searchTerm, null)
+ private val mozillaHistoryMetadataItem = History.Metadata(
+ position = 1,
+ title = "Mozilla",
+ url = "mozilla.org",
+ visitedAt = 0,
+ historyTimeGroup = HistoryItemTimeGroup.timeGroupForTimestamp(0),
+ totalViewTime = 1,
+ historyMetadataKey = historyMetadataKey,
+ )
+ private val firefoxHistoryMetadataItem = History.Metadata(
+ position = 1,
+ title = "Firefox",
+ url = "firefox.com",
+ visitedAt = 0,
+ historyTimeGroup = HistoryItemTimeGroup.timeGroupForTimestamp(0),
+ totalViewTime = 1,
+ historyMetadataKey = historyMetadataKey,
+ )
+
+ private lateinit var controller: DefaultHistoryMetadataGroupController
+
+ private fun getMetadataItemsList() =
+ listOf(mozillaHistoryMetadataItem, firefoxHistoryMetadataItem)
+
+ @Before
+ fun setUp() {
+ controller = createController()
+ every { activity.components.core.historyStorage } returns historyStorage
+ every { context.components.core.store } returns browserStore
+ every { context.components.core.historyStorage } returns historyStorage
+ every { store.state.items } returns getMetadataItemsList()
+ }
+
+ @Test
+ fun handleOpen() {
+ assertNull(GleanHistory.searchTermGroupOpenTab.testGetValue())
+
+ controller.handleOpen(mozillaHistoryMetadataItem)
+
+ verify {
+ selectOrAddUseCase.invoke(
+ mozillaHistoryMetadataItem.url,
+ mozillaHistoryMetadataItem.historyMetadataKey,
+ )
+ navController.navigate(R.id.browserFragment)
+ }
+ assertNotNull(GleanHistory.searchTermGroupOpenTab.testGetValue())
+ assertEquals(
+ 1,
+ GleanHistory.searchTermGroupOpenTab.testGetValue()!!.size,
+ )
+ assertNull(
+ GleanHistory.searchTermGroupOpenTab.testGetValue()!!
+ .single().extra,
+ )
+ }
+
+ @Test
+ fun handleSelect() {
+ controller.handleSelect(mozillaHistoryMetadataItem)
+
+ verify {
+ store.dispatch(HistoryMetadataGroupFragmentAction.Select(mozillaHistoryMetadataItem))
+ }
+ }
+
+ @Test
+ fun handleDeselect() {
+ controller.handleDeselect(mozillaHistoryMetadataItem)
+
+ verify {
+ store.dispatch(HistoryMetadataGroupFragmentAction.Deselect(mozillaHistoryMetadataItem))
+ }
+ }
+
+ @Test
+ fun handleBackPressed() {
+ assertTrue(controller.handleBackPressed(setOf(mozillaHistoryMetadataItem)))
+
+ verify {
+ store.dispatch(HistoryMetadataGroupFragmentAction.DeselectAll)
+ }
+
+ assertFalse(controller.handleBackPressed(emptySet()))
+ }
+
+ @Test
+ fun handleShare() {
+ controller.handleShare(setOf(mozillaHistoryMetadataItem, firefoxHistoryMetadataItem))
+
+ val data = arrayOf(
+ ShareData(
+ title = mozillaHistoryMetadataItem.title,
+ url = mozillaHistoryMetadataItem.url,
+ ),
+ ShareData(
+ title = firefoxHistoryMetadataItem.title,
+ url = firefoxHistoryMetadataItem.url,
+ ),
+ )
+
+ verify {
+ navController.navigate(
+ directionsEq(HistoryMetadataGroupFragmentDirections.actionGlobalShareFragment(data)),
+ )
+ }
+ }
+
+ @Test
+ fun handleDeleteSingle() = runTestOnMain {
+ assertNull(GleanHistory.searchTermGroupRemoveTab.testGetValue())
+
+ controller.handleDelete(setOf(mozillaHistoryMetadataItem))
+
+ coVerify {
+ store.dispatch(HistoryMetadataGroupFragmentAction.Delete(mozillaHistoryMetadataItem))
+ historyStorage.deleteVisitsFor(mozillaHistoryMetadataItem.url)
+ }
+ assertNotNull(GleanHistory.searchTermGroupRemoveTab.testGetValue())
+ assertEquals(
+ 1,
+ GleanHistory.searchTermGroupRemoveTab.testGetValue()!!.size,
+ )
+ assertNull(
+ GleanHistory.searchTermGroupRemoveTab.testGetValue()!!
+ .single().extra,
+ )
+ // Here we don't expect the action to be dispatched, because items inside the store
+ // we provided by getMetadataItemsList(), but only one item has been removed
+ verify(exactly = 0) {
+ browserStore.dispatch(
+ HistoryMetadataAction.DisbandSearchGroupAction(searchTerm = searchTerm),
+ )
+ }
+ }
+
+ @Test
+ fun handleDeleteMultiple() = runTestOnMain {
+ assertNull(GleanHistory.searchTermGroupRemoveTab.testGetValue())
+ controller.handleDelete(getMetadataItemsList().toSet())
+
+ coVerify {
+ getMetadataItemsList().forEach {
+ store.dispatch(HistoryMetadataGroupFragmentAction.Delete(it))
+ historyStorage.deleteVisitsFor(it.url)
+ }
+ }
+ assertNotNull(GleanHistory.searchTermGroupRemoveTab.testGetValue())
+ assertNull(
+ GleanHistory.searchTermGroupRemoveTab.testGetValue()!!
+ .last().extra,
+ )
+ // Here we expect the action to be dispatched, because both deleted items and items inside
+ // the store were provided by the same method getMetadataItemsList()
+ verify {
+ browserStore.dispatch(
+ HistoryMetadataAction.DisbandSearchGroupAction(searchTerm = searchTerm),
+ )
+ }
+ }
+
+ @Test
+ fun handleDeleteAbnormal() = runTestOnMain {
+ val abnormalList = listOf(
+ mozillaHistoryMetadataItem,
+ firefoxHistoryMetadataItem,
+ mozillaHistoryMetadataItem.copy(title = "Pocket", url = "https://getpocket.com"),
+ mozillaHistoryMetadataItem.copy(title = "BBC", url = "https://www.bbc.com/"),
+ mozillaHistoryMetadataItem.copy(
+ title = "Stackoverflow",
+ url = "https://stackoverflow.com/",
+ ),
+ )
+ assertNull(GleanHistory.searchTermGroupRemoveTab.testGetValue())
+
+ controller.handleDelete(abnormalList.toSet())
+ coVerify {
+ getMetadataItemsList().forEach {
+ store.dispatch(HistoryMetadataGroupFragmentAction.Delete(it))
+ historyStorage.deleteVisitsFor(it.url)
+ }
+ }
+ assertNotNull(GleanHistory.searchTermGroupRemoveTab.testGetValue())
+ assertNull(
+ GleanHistory.searchTermGroupRemoveTab.testGetValue()!!
+ .last().extra,
+ )
+ coVerify {
+ abnormalList.forEach {
+ store.dispatch(HistoryMetadataGroupFragmentAction.Delete(it))
+ historyStorage.deleteVisitsFor(it.url)
+ }
+ }
+ assertNotNull(GleanHistory.searchTermGroupRemoveTab.testGetValue())
+ assertNull(
+ GleanHistory.searchTermGroupRemoveTab.testGetValue()!!
+ .last().extra,
+ )
+ // Here we expect the action to be dispatched, because deleted items include the items
+ // provided by getMetadataItemsList(), so that the store becomes empty and the event
+ // should be sent
+ verify {
+ browserStore.dispatch(
+ HistoryMetadataAction.DisbandSearchGroupAction(searchTerm = searchTerm),
+ )
+ }
+ }
+
+ @Test
+ fun handleDeleteAll() = runTestOnMain {
+ var promptDeleteAllInvoked = false
+ val controller = createController(
+ promptDeleteAll = {
+ promptDeleteAllInvoked = true
+ },
+ )
+ controller.handleDeleteAll()
+ assertTrue(promptDeleteAllInvoked)
+ }
+
+ @Test
+ fun handleDeleteAllConfirmed() = runTestOnMain {
+ assertNull(GleanHistory.searchTermGroupRemoveAll.testGetValue())
+
+ controller.handleDeleteAllConfirmed()
+
+ coVerify {
+ store.dispatch(HistoryMetadataGroupFragmentAction.DeleteAll)
+ getMetadataItemsList().forEach {
+ historyStorage.deleteVisitsFor(it.url)
+ }
+ browserStore.dispatch(
+ HistoryMetadataAction.DisbandSearchGroupAction(searchTerm = searchTerm),
+ )
+ }
+ assertNotNull(GleanHistory.searchTermGroupRemoveAll.testGetValue())
+ assertEquals(
+ 1,
+ GleanHistory.searchTermGroupRemoveAll.testGetValue()!!.size,
+ )
+ assertNull(
+ GleanHistory.searchTermGroupRemoveAll.testGetValue()!!
+ .single().extra,
+ )
+ }
+
+ private fun createController(
+ deleteSnackbar: (
+ items: Set<History.Metadata>,
+ undo: suspend (Set<History.Metadata>) -> Unit,
+ delete: (Set<History.Metadata>) -> suspend (context: Context) -> Unit,
+ ) -> Unit = { items, _, delete ->
+ scope.launch {
+ delete(items).invoke(context)
+ }
+ },
+ promptDeleteAll: () -> Unit = {},
+ allDeletedSnackbar: () -> Unit = {},
+ ): DefaultHistoryMetadataGroupController {
+ return DefaultHistoryMetadataGroupController(
+ historyStorage = historyStorage,
+ browserStore = browserStore,
+ appStore = appStore,
+ store = store,
+ selectOrAddUseCase = selectOrAddUseCase,
+ navController = navController,
+ scope = scope,
+ searchTerm = searchTerm,
+ deleteSnackbar = deleteSnackbar,
+ promptDeleteAll = promptDeleteAll,
+ allDeletedSnackbar = allDeletedSnackbar,
+ )
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/historymetadata/view/HistoryMetadataGroupItemViewHolderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/historymetadata/view/HistoryMetadataGroupItemViewHolderTest.kt
new file mode 100644
index 0000000000..f0271f8820
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/historymetadata/view/HistoryMetadataGroupItemViewHolderTest.kt
@@ -0,0 +1,58 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.fenix.library.historymetadata.view
+
+import android.view.LayoutInflater
+import androidx.navigation.Navigation
+import io.mockk.every
+import io.mockk.mockk
+import mozilla.components.browser.icons.BrowserIcons
+import mozilla.components.concept.storage.HistoryMetadataKey
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.databinding.HistoryMetadataGroupListItemBinding
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.library.history.History
+import org.mozilla.fenix.library.history.HistoryItemTimeGroup
+import org.mozilla.fenix.library.historymetadata.interactor.HistoryMetadataGroupInteractor
+import org.mozilla.fenix.selection.SelectionHolder
+
+@RunWith(FenixRobolectricTestRunner::class)
+class HistoryMetadataGroupItemViewHolderTest {
+
+ private lateinit var binding: HistoryMetadataGroupListItemBinding
+ private lateinit var interactor: HistoryMetadataGroupInteractor
+ private lateinit var selectionHolder: SelectionHolder<History.Metadata>
+
+ private val item = History.Metadata(
+ position = 1,
+ title = "Mozilla",
+ url = "mozilla.org",
+ visitedAt = 0,
+ historyTimeGroup = HistoryItemTimeGroup.timeGroupForTimestamp(0),
+ totalViewTime = 0,
+ historyMetadataKey = HistoryMetadataKey("http://www.mozilla.com", "mozilla", null),
+ )
+
+ @Before
+ fun setup() {
+ binding = HistoryMetadataGroupListItemBinding.inflate(LayoutInflater.from(testContext))
+ Navigation.setViewNavController(binding.root, mockk(relaxed = true))
+ interactor = mockk(relaxed = true)
+ selectionHolder = mockk(relaxed = true)
+ }
+
+ @Test
+ fun `GIVEN a history metadata item on bind THEN set the title and url text`() {
+ every { testContext.components.core.icons } returns BrowserIcons(testContext, mockk(relaxed = true))
+ HistoryMetadataGroupItemViewHolder(binding.root, interactor, selectionHolder).bind(item, isPendingDeletion = false)
+
+ assertEquals(item.title, binding.historyLayout.titleView.text)
+ assertEquals(item.url, binding.historyLayout.urlView.text)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/recentlyclosed/DefaultRecentlyClosedControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/recentlyclosed/DefaultRecentlyClosedControllerTest.kt
new file mode 100644
index 0000000000..57caa895d4
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/recentlyclosed/DefaultRecentlyClosedControllerTest.kt
@@ -0,0 +1,314 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.recentlyclosed
+
+import androidx.navigation.NavController
+import androidx.navigation.NavOptions
+import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.state.action.RecentlyClosedAction
+import mozilla.components.browser.state.state.recover.TabState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.prompt.ShareData
+import mozilla.components.feature.recentlyclosed.RecentlyClosedTabsStorage
+import mozilla.components.feature.tabs.TabsUseCases
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.telemetry.glean.testing.GleanTestRule
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.RecentlyClosedTabs
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.R
+import org.mozilla.fenix.browser.browsingmode.BrowsingMode
+import org.mozilla.fenix.ext.directionsEq
+import org.mozilla.fenix.ext.optionsEq
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class DefaultRecentlyClosedControllerTest {
+ private val navController: NavController = mockk(relaxed = true)
+ private val activity: HomeActivity = mockk(relaxed = true)
+ private val browserStore: BrowserStore = mockk(relaxed = true)
+ private val recentlyClosedStore: RecentlyClosedFragmentStore = mockk(relaxed = true)
+ private val tabsUseCases: TabsUseCases = mockk(relaxed = true)
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @Before
+ fun setUp() {
+ coEvery { tabsUseCases.restore.invoke(any(), any(), true) } just Runs
+ }
+
+ @Test
+ fun handleOpen() {
+ val item: TabState = mockk(relaxed = true)
+
+ var tabUrl: String? = null
+ var actualBrowsingMode: BrowsingMode? = null
+
+ val controller = createController(
+ openToBrowser = { url, browsingMode ->
+ tabUrl = url
+ actualBrowsingMode = browsingMode
+ },
+ )
+
+ controller.handleOpen(item, BrowsingMode.Private)
+
+ assertEquals(item.url, tabUrl)
+ assertEquals(actualBrowsingMode, BrowsingMode.Private)
+
+ tabUrl = null
+ actualBrowsingMode = null
+
+ controller.handleOpen(item, BrowsingMode.Normal)
+
+ assertEquals(item.url, tabUrl)
+ assertEquals(actualBrowsingMode, BrowsingMode.Normal)
+ }
+
+ @Test
+ fun `open multiple tabs`() {
+ val tabs = createFakeTabList(2)
+
+ val tabUrls = mutableListOf<String>()
+ val actualBrowsingModes = mutableListOf<BrowsingMode?>()
+
+ val controller = createController(
+ openToBrowser = { url, mode ->
+ tabUrls.add(url)
+ actualBrowsingModes.add(mode)
+ },
+ )
+ assertNull(RecentlyClosedTabs.menuOpenInNormalTab.testGetValue())
+
+ controller.handleOpen(tabs.toSet(), BrowsingMode.Normal)
+
+ assertEquals(2, tabUrls.size)
+ assertEquals(tabs[0].url, tabUrls[0])
+ assertEquals(tabs[1].url, tabUrls[1])
+ assertEquals(BrowsingMode.Normal, actualBrowsingModes[0])
+ assertEquals(BrowsingMode.Normal, actualBrowsingModes[1])
+ assertNotNull(RecentlyClosedTabs.menuOpenInNormalTab.testGetValue())
+ assertNull(RecentlyClosedTabs.menuOpenInNormalTab.testGetValue()!!.last().extra)
+
+ tabUrls.clear()
+ actualBrowsingModes.clear()
+
+ controller.handleOpen(tabs.toSet(), BrowsingMode.Private)
+
+ assertEquals(2, tabUrls.size)
+ assertEquals(tabs[0].url, tabUrls[0])
+ assertEquals(tabs[1].url, tabUrls[1])
+ assertEquals(BrowsingMode.Private, actualBrowsingModes[0])
+ assertEquals(BrowsingMode.Private, actualBrowsingModes[1])
+ assertNotNull(RecentlyClosedTabs.menuOpenInPrivateTab.testGetValue())
+ assertEquals(1, RecentlyClosedTabs.menuOpenInPrivateTab.testGetValue()!!.size)
+ assertNull(RecentlyClosedTabs.menuOpenInPrivateTab.testGetValue()!!.single().extra)
+ }
+
+ @Test
+ fun `handle selecting first tab`() {
+ val selectedTab = createFakeTab()
+ every { recentlyClosedStore.state.selectedTabs } returns emptySet()
+ assertNull(RecentlyClosedTabs.enterMultiselect.testGetValue())
+
+ createController().handleSelect(selectedTab)
+
+ verify { recentlyClosedStore.dispatch(RecentlyClosedFragmentAction.Select(selectedTab)) }
+ assertNotNull(RecentlyClosedTabs.enterMultiselect.testGetValue())
+ assertEquals(1, RecentlyClosedTabs.enterMultiselect.testGetValue()!!.size)
+ assertNull(RecentlyClosedTabs.enterMultiselect.testGetValue()!!.single().extra)
+ }
+
+ @Test
+ fun `handle selecting a successive tab`() {
+ val selectedTab = createFakeTab()
+ every { recentlyClosedStore.state.selectedTabs } returns setOf(mockk())
+
+ createController().handleSelect(selectedTab)
+
+ verify { recentlyClosedStore.dispatch(RecentlyClosedFragmentAction.Select(selectedTab)) }
+ assertNull(RecentlyClosedTabs.enterMultiselect.testGetValue())
+ }
+
+ @Test
+ fun `handle deselect last tab`() {
+ val deselectedTab = createFakeTab()
+ every { recentlyClosedStore.state.selectedTabs } returns setOf(deselectedTab)
+ assertNull(RecentlyClosedTabs.exitMultiselect.testGetValue())
+
+ createController().handleDeselect(deselectedTab)
+
+ verify { recentlyClosedStore.dispatch(RecentlyClosedFragmentAction.Deselect(deselectedTab)) }
+ assertNotNull(RecentlyClosedTabs.exitMultiselect.testGetValue())
+ assertEquals(1, RecentlyClosedTabs.exitMultiselect.testGetValue()!!.size)
+ assertNull(RecentlyClosedTabs.exitMultiselect.testGetValue()!!.single().extra)
+ }
+
+ @Test
+ fun `handle deselect a tab from others still selected`() {
+ val deselectedTab = createFakeTab()
+ every { recentlyClosedStore.state.selectedTabs } returns setOf(deselectedTab, mockk())
+
+ createController().handleDeselect(deselectedTab)
+
+ verify { recentlyClosedStore.dispatch(RecentlyClosedFragmentAction.Deselect(deselectedTab)) }
+ assertNull(RecentlyClosedTabs.exitMultiselect.testGetValue())
+ }
+
+ @Test
+ fun handleDelete() {
+ val item: TabState = mockk(relaxed = true)
+ assertNull(RecentlyClosedTabs.deleteTab.testGetValue())
+
+ createController().handleDelete(item)
+
+ verify {
+ browserStore.dispatch(RecentlyClosedAction.RemoveClosedTabAction(item))
+ }
+ assertNotNull(RecentlyClosedTabs.deleteTab.testGetValue())
+ assertEquals(1, RecentlyClosedTabs.deleteTab.testGetValue()!!.size)
+ assertNull(RecentlyClosedTabs.deleteTab.testGetValue()!!.single().extra)
+ }
+
+ @Test
+ fun `delete multiple tabs`() {
+ val tabs = createFakeTabList(2)
+ assertNull(RecentlyClosedTabs.menuDelete.testGetValue())
+
+ createController().handleDelete(tabs.toSet())
+
+ verify {
+ browserStore.dispatch(RecentlyClosedAction.RemoveClosedTabAction(tabs[0]))
+ browserStore.dispatch(RecentlyClosedAction.RemoveClosedTabAction(tabs[1]))
+ }
+ assertNotNull(RecentlyClosedTabs.menuDelete.testGetValue())
+ assertNull(RecentlyClosedTabs.menuDelete.testGetValue()!!.last().extra)
+ }
+
+ @Test
+ fun handleNavigateToHistory() {
+ assertNull(RecentlyClosedTabs.showFullHistory.testGetValue())
+
+ createController().handleNavigateToHistory()
+
+ verify {
+ navController.navigate(
+ directionsEq(
+ RecentlyClosedFragmentDirections.actionGlobalHistoryFragment(),
+ ),
+ optionsEq(NavOptions.Builder().setPopUpTo(R.id.historyFragment, true).build()),
+ )
+ }
+ assertNotNull(RecentlyClosedTabs.showFullHistory.testGetValue())
+ assertEquals(1, RecentlyClosedTabs.showFullHistory.testGetValue()!!.size)
+ assertNull(RecentlyClosedTabs.showFullHistory.testGetValue()!!.single().extra)
+ }
+
+ @Test
+ fun `share multiple tabs`() {
+ val tabs = createFakeTabList(2)
+ assertNull(RecentlyClosedTabs.menuShare.testGetValue())
+
+ createController().handleShare(tabs.toSet())
+
+ verify {
+ val data = arrayOf(
+ ShareData(title = tabs[0].title, url = tabs[0].url),
+ ShareData(title = tabs[1].title, url = tabs[1].url),
+ )
+ navController.navigate(
+ directionsEq(RecentlyClosedFragmentDirections.actionGlobalShareFragment(data)),
+ )
+ }
+ assertNotNull(RecentlyClosedTabs.menuShare.testGetValue())
+ assertEquals(1, RecentlyClosedTabs.menuShare.testGetValue()!!.size)
+ assertNull(RecentlyClosedTabs.menuShare.testGetValue()!!.single().extra)
+ }
+
+ @Test
+ fun handleRestore() = runTest {
+ val item: TabState = mockk(relaxed = true)
+ assertNull(RecentlyClosedTabs.openTab.testGetValue())
+
+ createController(scope = this).handleRestore(item)
+ runCurrent()
+
+ coVerify { tabsUseCases.restore.invoke(eq(item), any(), true) }
+ assertNotNull(RecentlyClosedTabs.openTab.testGetValue())
+ assertEquals(1, RecentlyClosedTabs.openTab.testGetValue()!!.size)
+ assertNull(RecentlyClosedTabs.openTab.testGetValue()!!.single().extra)
+ }
+
+ @Test
+ fun `exist multi-select mode when back is pressed`() {
+ every { recentlyClosedStore.state.selectedTabs } returns createFakeTabList(3).toSet()
+ assertNull(RecentlyClosedTabs.exitMultiselect.testGetValue())
+
+ createController().handleBackPressed()
+
+ verify { recentlyClosedStore.dispatch(RecentlyClosedFragmentAction.DeselectAll) }
+ assertNotNull(RecentlyClosedTabs.exitMultiselect.testGetValue())
+ assertEquals(1, RecentlyClosedTabs.exitMultiselect.testGetValue()!!.size)
+ assertNull(RecentlyClosedTabs.exitMultiselect.testGetValue()!!.single().extra)
+ }
+
+ @Test
+ fun `report closing the fragment when back is pressed`() {
+ every { recentlyClosedStore.state.selectedTabs } returns emptySet()
+ assertNull(RecentlyClosedTabs.closed.testGetValue())
+
+ createController().handleBackPressed()
+
+ verify(exactly = 0) { recentlyClosedStore.dispatch(RecentlyClosedFragmentAction.DeselectAll) }
+ assertNotNull(RecentlyClosedTabs.closed.testGetValue())
+ assertEquals(1, RecentlyClosedTabs.closed.testGetValue()!!.size)
+ assertNull(RecentlyClosedTabs.closed.testGetValue()!!.single().extra)
+ }
+
+ private fun createController(
+ scope: CoroutineScope = CoroutineScope(Dispatchers.IO),
+ openToBrowser: (String, BrowsingMode?) -> Unit = { _, _ -> },
+ ): RecentlyClosedController {
+ return DefaultRecentlyClosedController(
+ navController,
+ browserStore,
+ recentlyClosedStore,
+ RecentlyClosedTabsStorage(testContext, mockk(), mockk()),
+ tabsUseCases,
+ activity,
+ scope,
+ openToBrowser,
+ )
+ }
+
+ private fun createFakeTab(id: String = "FakeId", url: String = "www.fake.com"): TabState =
+ TabState(id, url)
+
+ private fun createFakeTabList(size: Int): List<TabState> {
+ val fakeTabs = mutableListOf<TabState>()
+ for (i in 0 until size) {
+ fakeTabs.add(createFakeTab(id = "FakeId$i"))
+ }
+
+ return fakeTabs
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedFragmentInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedFragmentInteractorTest.kt
new file mode 100644
index 0000000000..9066cf7f63
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedFragmentInteractorTest.kt
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.library.recentlyclosed
+
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.browser.state.state.recover.TabState
+import org.junit.Before
+import org.junit.Test
+
+class RecentlyClosedFragmentInteractorTest {
+
+ lateinit var interactor: RecentlyClosedFragmentInteractor
+ private val defaultRecentlyClosedController: DefaultRecentlyClosedController =
+ mockk(relaxed = true)
+
+ @Before
+ fun setup() {
+ interactor =
+ RecentlyClosedFragmentInteractor(
+ recentlyClosedController = defaultRecentlyClosedController,
+ )
+ }
+
+ @Test
+ fun onDelete() {
+ val tab = TabState(id = "tab-id", title = "Mozilla", url = "mozilla.org", lastAccess = 1L)
+ interactor.onDelete(tab)
+
+ verify {
+ defaultRecentlyClosedController.handleDelete(tab)
+ }
+ }
+
+ @Test
+ fun onNavigateToHistory() {
+ interactor.onNavigateToHistory()
+
+ verify {
+ defaultRecentlyClosedController.handleNavigateToHistory()
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/lifecycle/StoreLifecycleObserverTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/lifecycle/StoreLifecycleObserverTest.kt
new file mode 100644
index 0000000000..4b14a1d506
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/lifecycle/StoreLifecycleObserverTest.kt
@@ -0,0 +1,52 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.lifecycle
+
+import androidx.test.core.app.ApplicationProvider
+import io.mockk.mockk
+import io.mockk.verifySequence
+import mozilla.components.browser.state.action.AppLifecycleAction
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.service.glean.testing.GleanTestRule
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppAction
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class StoreLifecycleObserverTest {
+ @get:Rule
+ val gleanRule = GleanTestRule(ApplicationProvider.getApplicationContext())
+
+ @Test
+ fun `WHEN onPause is called THEN dispatch PauseAction`() {
+ val appStore: AppStore = mockk(relaxed = true)
+ val browserStore: BrowserStore = mockk(relaxed = true)
+ val observer = StoreLifecycleObserver(appStore, browserStore)
+
+ observer.onPause(mockk())
+
+ verifySequence {
+ appStore.dispatch(AppAction.AppLifecycleAction.PauseAction)
+ browserStore.dispatch(AppLifecycleAction.PauseAction)
+ }
+ }
+
+ @Test
+ fun `WHEN onResume is called THEN dispatch ResumeAction`() {
+ val appStore: AppStore = mockk(relaxed = true)
+ val browserStore: BrowserStore = mockk(relaxed = true)
+ val observer = StoreLifecycleObserver(appStore, browserStore)
+
+ observer.onResume(mockk())
+
+ verifySequence {
+ appStore.dispatch(AppAction.AppLifecycleAction.ResumeAction)
+ browserStore.dispatch(AppLifecycleAction.ResumeAction)
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/menu/BrowserMenuSignInTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/menu/BrowserMenuSignInTest.kt
new file mode 100644
index 0000000000..e3476dbb96
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/menu/BrowserMenuSignInTest.kt
@@ -0,0 +1,93 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.menu
+
+import android.R
+import android.content.Context
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.mockk.every
+import io.mockk.mockk
+import mozilla.components.service.fxa.store.Account
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.components.Components
+import org.mozilla.fenix.components.toolbar.BrowserMenuSignIn
+import org.mozilla.fenix.ext.components
+
+@RunWith(AndroidJUnit4::class)
+class BrowserMenuSignInTest {
+ private lateinit var context: Context
+ private lateinit var components: Components
+ private val account: Account = mockk {
+ every { displayName } returns "bugzilla"
+ every { email } returns "bugzilla@mozilla.com"
+ }
+
+ private val accountWithNoDisplayName: Account = mockk {
+ every { displayName } returns null
+ every { email } returns "bugzilla@mozilla.com"
+ }
+
+ @Before
+ fun setup() {
+ context = mockk(relaxed = true)
+ components = mockk(relaxed = true)
+
+ every { context.resources } returns testContext.resources
+ every { context.components } returns components
+ }
+
+ @Test
+ fun `WHEN signed in and has profile data, THEN show display name`() {
+ every { components.backgroundServices.syncStore.state.account } returns account
+ every { components.settings.signedInFxaAccount } returns true
+
+ assertEquals(account.displayName, BrowserMenuSignIn(R.color.black).getLabel(context))
+ }
+
+ @Test
+ fun `WHEN signed in and has profile data but the display name is not set, THEN show email`() {
+ every { components.backgroundServices.syncStore.state.account } returns accountWithNoDisplayName
+ every { components.settings.signedInFxaAccount } returns true
+
+ assertEquals(accountWithNoDisplayName.email, BrowserMenuSignIn(R.color.black).getLabel(context))
+ }
+
+ @Test
+ fun `WHEN not signed in, THEN show the sync and save data text`() {
+ every { components.settings.signedInFxaAccount } returns false
+ every { components.backgroundServices.syncStore.state.account } returns null
+
+ assertEquals(
+ testContext.getString(org.mozilla.fenix.R.string.sync_menu_sync_and_save_data),
+ BrowserMenuSignIn(R.color.black).getLabel(context),
+ )
+ }
+
+ @Test
+ fun `WHEN not signed in and has profile data, THEN show the sync and save data text`() {
+ every { components.settings.signedInFxaAccount } returns false
+ every { components.backgroundServices.syncStore.state.account } returns account
+
+ assertEquals(
+ testContext.getString(org.mozilla.fenix.R.string.sync_menu_sync_and_save_data),
+ BrowserMenuSignIn(R.color.black).getLabel(context),
+ )
+ }
+
+ @Test
+ fun `WHEN signed in and has no profile data, THEN show the account info text`() {
+ every { components.settings.signedInFxaAccount } returns true
+ every { components.backgroundServices.syncStore.state.account } returns null
+
+ assertEquals(
+ testContext.getString(org.mozilla.fenix.R.string.browser_menu_account_settings),
+ BrowserMenuSignIn(R.color.black).getLabel(context),
+ )
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/messaging/DefaultMessageControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/messaging/DefaultMessageControllerTest.kt
new file mode 100644
index 0000000000..d94c96dfc4
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/messaging/DefaultMessageControllerTest.kt
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.messaging
+
+import android.content.Intent
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.service.nimbus.messaging.Message
+import mozilla.components.service.nimbus.messaging.MessageData
+import mozilla.components.service.nimbus.messaging.NimbusMessagingControllerInterface
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.telemetry.glean.testing.GleanTestRule
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppAction
+import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.MessageClicked
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class DefaultMessageControllerTest {
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ private val homeActivity: HomeActivity = mockk(relaxed = true)
+ private val messagingController: NimbusMessagingControllerInterface = mockk(relaxed = true)
+ private lateinit var defaultMessageController: DefaultMessageController
+ private val appStore: AppStore = mockk(relaxed = true)
+
+ @Before
+ fun setup() {
+ defaultMessageController = DefaultMessageController(
+ messagingController = messagingController,
+ appStore = appStore,
+ homeActivity = homeActivity,
+ )
+ }
+
+ @Test
+ fun `WHEN calling onMessagePressed THEN process the action intent and update the app store`() {
+ val message = mockMessage()
+ every { messagingController.getIntentForMessage(message) }.returns(Intent())
+
+ defaultMessageController.onMessagePressed(message)
+
+ verify { messagingController.getIntentForMessage(message) }
+ verify { homeActivity.processIntent(any()) }
+ verify { appStore.dispatch(MessageClicked(message)) }
+ }
+
+ @Test
+ fun `WHEN calling onMessageDismissed THEN update the app store`() {
+ val message = mockMessage()
+
+ defaultMessageController.onMessageDismissed(message)
+
+ verify { appStore.dispatch(AppAction.MessagingAction.MessageDismissed(message)) }
+ }
+
+ private fun mockMessage(data: MessageData = MessageData()) = Message(
+ id = "id",
+ data = data,
+ style = mockk(relaxed = true),
+ action = "action",
+ triggerIfAll = emptyList(),
+ excludeIfAny = emptyList(),
+ metadata = Message.Metadata(
+ id = "id",
+ displayCount = 0,
+ pressed = false,
+ dismissed = false,
+ ),
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/messaging/MessagingFeatureTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/messaging/MessagingFeatureTest.kt
new file mode 100644
index 0000000000..838e852f42
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/messaging/MessagingFeatureTest.kt
@@ -0,0 +1,30 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.messaging
+
+import io.mockk.spyk
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppAction.MessagingAction
+
+class MessagingFeatureTest {
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Test
+ fun `WHEN start is called THEN evaluate messages`() {
+ val appStore: AppStore = spyk(AppStore())
+ val binding = MessagingFeature(appStore)
+
+ binding.start()
+
+ verify { appStore.dispatch(MessagingAction.Evaluate(FenixMessageSurfaceId.HOMESCREEN)) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/messaging/state/MessagingMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/messaging/state/MessagingMiddlewareTest.kt
new file mode 100644
index 0000000000..88b9161583
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/messaging/state/MessagingMiddlewareTest.kt
@@ -0,0 +1,382 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.messaging.state
+
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.advanceUntilIdle
+import mozilla.components.service.nimbus.messaging.Message
+import mozilla.components.service.nimbus.messaging.MessageData
+import mozilla.components.service.nimbus.messaging.NimbusMessagingController
+import mozilla.components.service.nimbus.messaging.StyleData
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.Evaluate
+import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.MessageClicked
+import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.MessageDismissed
+import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.Restore
+import org.mozilla.fenix.components.appstate.AppState
+import org.mozilla.fenix.messaging.FenixMessageSurfaceId
+import org.mozilla.fenix.messaging.MessagingState
+
+class MessagingMiddlewareTest {
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val coroutineScope = coroutinesTestRule.scope
+ private lateinit var controller: NimbusMessagingController
+
+ @Before
+ fun setUp() {
+ controller = mockk(relaxed = true)
+ }
+
+ @Test
+ fun `WHEN restored THEN get messages from the storage`() = runTestOnMain {
+ val store = AppStore(
+ AppState(
+ messaging = MessagingState(
+ messages = emptyList(),
+ ),
+ ),
+ listOf(
+ MessagingMiddleware(controller, coroutineScope),
+ ),
+ )
+ val message = createMessage()
+
+ coEvery { controller.getMessages() } returns listOf(message)
+
+ store.dispatch(Restore).joinBlocking()
+ store.waitUntilIdle()
+ coroutineScope.advanceUntilIdle()
+
+ assertEquals(listOf(message), store.state.messaging.messages)
+ }
+
+ @Test
+ fun `WHEN Evaluate THEN getNextMessage from the storage and UpdateMessageToShow`() = runTestOnMain {
+ val message = createMessage()
+ val store = AppStore(
+ AppState(
+ messaging = MessagingState(
+ messages = listOf(
+ message,
+ ),
+ ),
+ ),
+ listOf(
+ MessagingMiddleware(controller, coroutineScope),
+ ),
+ )
+
+ every {
+ controller.getNextMessage(
+ FenixMessageSurfaceId.HOMESCREEN,
+ any(),
+ )
+ } returns message
+
+ assertEquals(0, store.state.messaging.messageToShow.size)
+
+ store.dispatch(Evaluate(FenixMessageSurfaceId.HOMESCREEN)).joinBlocking()
+ store.waitUntilIdle()
+
+ // UpdateMessageToShow to causes messageToShow to append
+ assertEquals(1, store.state.messaging.messageToShow.size)
+ }
+
+ @Test
+ fun `WHEN MessageClicked THEN update storage`() = runTestOnMain {
+ val message = createMessage()
+ val store = AppStore(
+ AppState(
+ messaging = MessagingState(
+ messages = listOf(message),
+ ),
+ ),
+ listOf(
+ MessagingMiddleware(controller, coroutineScope),
+ ),
+ )
+
+ assertEquals(message, store.state.messaging.messages.first())
+
+ store.dispatch(MessageClicked(message)).joinBlocking()
+ store.waitUntilIdle()
+
+ assertTrue(store.state.messaging.messages.isEmpty())
+ coVerify { controller.onMessageClicked(message = message) }
+ }
+
+ @Test
+ fun `WHEN MessageDismissed THEN update storage`() = runTestOnMain {
+ val metadata = createMetadata(displayCount = 1, "same-id")
+ val message = createMessage(metadata)
+ val store = AppStore(
+ AppState(
+ messaging = MessagingState(
+ messages = listOf(message),
+ ),
+ ),
+ listOf(
+ MessagingMiddleware(controller, coroutineScope),
+ ),
+ )
+ store.dispatch(MessageDismissed(message)).joinBlocking()
+ store.waitUntilIdle()
+
+ assertTrue(store.state.messaging.messages.isEmpty())
+ coVerify { controller.onMessageDismissed(message = message) }
+ }
+
+ @Test
+ fun `WHEN onMessageDismissed THEN remove the message from storage and state`() = runTestOnMain {
+ val message = createMessage(createMetadata(displayCount = 1))
+ val store = AppStore(
+ AppState(
+ messaging = MessagingState(
+ messages = listOf(
+ message,
+ ),
+ messageToShow = mapOf(message.surface to message),
+ ),
+ ),
+ listOf(
+ MessagingMiddleware(controller, coroutineScope),
+ ),
+ )
+
+ store.dispatch(MessageDismissed(message)).joinBlocking()
+ store.waitUntilIdle()
+
+ // removeMessages causes messages size to be 0
+ assertEquals(0, store.state.messaging.messages.size)
+ // consumeMessageToShowIfNeeded causes messageToShow map size to be 0
+ assertEquals(0, store.state.messaging.messageToShow.size)
+ }
+
+ @Test
+ fun `WHEN consumeMessageToShowIfNeeded THEN consume the message`() = runTestOnMain {
+ val message = createMessage()
+ val store = AppStore(
+ AppState(
+ messaging = MessagingState(
+ messages = listOf(
+ message,
+ ),
+ messageToShow = mapOf(message.surface to message),
+ ),
+ ),
+ listOf(
+ MessagingMiddleware(controller, coroutineScope),
+ ),
+ )
+
+ store.dispatch(MessageClicked(message)).joinBlocking()
+ store.waitUntilIdle()
+
+ assertTrue(store.state.messaging.messages.isEmpty())
+ assertTrue(store.state.messaging.messageToShow.isEmpty())
+ }
+
+ @Test
+ fun `WHEN updateMessage THEN update available messages`() = runTestOnMain {
+ val message = createMessage()
+ val messageDisplayed = message.copy(metadata = createMetadata(displayCount = 1))
+ val store = AppStore(
+ AppState(
+ messaging = MessagingState(
+ messages = listOf(
+ message,
+ ),
+ ),
+ ),
+ listOf(
+ MessagingMiddleware(controller, coroutineScope),
+ ),
+ )
+
+ every {
+ controller.getNextMessage(
+ FenixMessageSurfaceId.HOMESCREEN,
+ any(),
+ )
+ } returns message
+
+ coEvery {
+ controller.onMessageDisplayed(eq(message), any())
+ } returns messageDisplayed
+
+ store.dispatch(Evaluate(FenixMessageSurfaceId.HOMESCREEN)).joinBlocking()
+ store.waitUntilIdle()
+
+ assertEquals(1, store.state.messaging.messages.count())
+ assertEquals(1, store.state.messaging.messages.first().displayCount)
+ }
+
+ @Test
+ fun `WHEN evaluate THEN update displayCount without altering message order`() = runTestOnMain {
+ val message1 = createMessage()
+ val message2 = message1.copy(id = "message2", action = "action2")
+ // An updated message1 that has been displayed once.
+ val messageDisplayed1 = incrementDisplayCount(message1)
+ val store = AppStore(
+ AppState(
+ messaging = MessagingState(
+ messages = listOf(
+ message1,
+ message2,
+ ),
+ ),
+ ),
+ listOf(
+ MessagingMiddleware(controller, coroutineScope),
+ ),
+ )
+
+ every {
+ controller.getNextMessage(
+ FenixMessageSurfaceId.HOMESCREEN,
+ any(),
+ )
+ } returns message1
+ coEvery {
+ controller.onMessageDisplayed(message1, any())
+ } returns messageDisplayed1
+
+ coEvery {
+ controller.onMessageDisplayed(eq(message1), any())
+ } returns messageDisplayed1
+
+ store.dispatch(Evaluate(FenixMessageSurfaceId.HOMESCREEN)).joinBlocking()
+ store.waitUntilIdle()
+
+ assertEquals(messageDisplayed1, store.state.messaging.messages[0])
+ assertEquals(message2, store.state.messaging.messages[1])
+ }
+
+ @Test
+ fun `GIVEN a message has not surpassed the maxDisplayCount WHEN evaluate THEN update the message displayCount`() = runTestOnMain {
+ val message = createMessage()
+ // An updated message that has been displayed once.
+ val messageDisplayed = incrementDisplayCount(message)
+ val store = AppStore(
+ AppState(
+ messaging = MessagingState(
+ messages = listOf(
+ message,
+ ),
+ ),
+ ),
+ listOf(
+ MessagingMiddleware(controller, coroutineScope),
+ ),
+ )
+
+ every {
+ controller.getNextMessage(
+ FenixMessageSurfaceId.HOMESCREEN,
+ any(),
+ )
+ } returns message
+ coEvery {
+ controller.onMessageDisplayed(message, any())
+ } returns messageDisplayed
+
+ coEvery {
+ controller.onMessageDisplayed(eq(message), any())
+ } returns messageDisplayed
+
+ store.dispatch(Evaluate(FenixMessageSurfaceId.HOMESCREEN)).joinBlocking()
+ store.waitUntilIdle()
+
+ assertEquals(messageDisplayed.displayCount, store.state.messaging.messages[0].displayCount)
+ }
+
+ @Test
+ fun `GIVEN a message with that surpassed the maxDisplayCount WHEN onMessagedDisplayed THEN remove the message and consume it`() = runTestOnMain {
+ val message = createMessage(createMetadata(displayCount = 4))
+ val messageDisplayed = createMessage(createMetadata(displayCount = 5))
+ assertFalse(message.isExpired)
+ assertTrue(messageDisplayed.isExpired)
+
+ val store = AppStore(
+ AppState(
+ messaging = MessagingState(
+ messages = listOf(
+ message,
+ ),
+ messageToShow = mapOf(message.surface to message),
+ ),
+ ),
+ listOf(
+ MessagingMiddleware(controller, coroutineScope),
+ ),
+ )
+
+ every {
+ controller.getNextMessage(
+ FenixMessageSurfaceId.HOMESCREEN,
+ any(),
+ )
+ } returns message
+ coEvery {
+ controller.onMessageDisplayed(message, any())
+ } returns incrementDisplayCount(message)
+
+ coEvery {
+ controller.onMessageDisplayed(eq(message), any())
+ } returns messageDisplayed
+
+ store.dispatch(Evaluate(FenixMessageSurfaceId.HOMESCREEN)).joinBlocking()
+ store.waitUntilIdle()
+
+ assertEquals(0, store.state.messaging.messages.size)
+ assertEquals(0, store.state.messaging.messageToShow.size)
+ }
+}
+private fun createMessage(
+ metadata: Message.Metadata = createMetadata(),
+ messageId: String = "message-id",
+ data: MessageData = mockk(relaxed = true),
+ action: String = "action",
+ styleData: StyleData = StyleData(),
+ triggers: List<String> = listOf("triggers"),
+ except: List<String> = listOf(),
+) = Message(messageId, data, action, styleData, triggers, except, metadata)
+
+private fun createMetadata(
+ displayCount: Int = 0,
+ id: String = "same-id",
+ pressed: Boolean = false,
+ dismissed: Boolean = false,
+ lastTimeShown: Long = 0L,
+ latestBootIdentifier: String? = null,
+) = Message.Metadata(
+ id,
+ displayCount,
+ pressed,
+ dismissed,
+ lastTimeShown,
+ latestBootIdentifier,
+)
+
+private fun incrementDisplayCount(message: Message) =
+ message.copy(
+ metadata = createMetadata(
+ displayCount = message.displayCount + 1,
+ ),
+ )
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/messaging/state/MessagingReducerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/messaging/state/MessagingReducerTest.kt
new file mode 100644
index 0000000000..43d1f44e94
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/messaging/state/MessagingReducerTest.kt
@@ -0,0 +1,78 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.messaging.state
+
+import io.mockk.mockk
+import mozilla.components.service.nimbus.messaging.Message
+import mozilla.components.service.nimbus.messaging.MessageData
+import mozilla.components.service.nimbus.messaging.MessageSurfaceId
+import mozilla.components.service.nimbus.messaging.StyleData
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.ConsumeMessageToShow
+import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.UpdateMessageToShow
+import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.UpdateMessages
+import org.mozilla.fenix.components.appstate.AppState
+import org.mozilla.fenix.components.appstate.AppStoreReducer
+import org.mozilla.fenix.messaging.FenixMessageSurfaceId
+import org.mozilla.fenix.messaging.MessagingState
+
+class MessagingReducerTest {
+
+ @Test
+ fun `GIVEN a new value for messageToShow WHEN UpdateMessageToShow is called THEN update the current value`() {
+ val initialState = AppState(
+ messaging = MessagingState(
+ messageToShow = mapOf(),
+ ),
+ )
+
+ val m = createMessage("message1")
+
+ var updatedState = MessagingReducer.reduce(
+ initialState,
+ UpdateMessageToShow(m),
+ )
+
+ assertNotNull(updatedState.messaging.messageToShow[m.surface])
+
+ updatedState = AppStoreReducer.reduce(updatedState, ConsumeMessageToShow(m.surface))
+
+ assertNull(updatedState.messaging.messageToShow[m.surface])
+ }
+
+ private fun createMessage(id: String, action: String = "action-1", surface: MessageSurfaceId = FenixMessageSurfaceId.HOMESCREEN): Message =
+ Message(
+ id = id,
+ data = MessageData(surface = surface),
+ action = action,
+ style = StyleData(),
+ triggerIfAll = listOf(),
+ metadata = Message.Metadata(id = id),
+ )
+
+ @Test
+ fun `GIVEN a new value for messages WHEN UpdateMessages is called THEN update the current value`() {
+ val initialState = AppState(
+ messaging = MessagingState(
+ messages = emptyList(),
+ ),
+ )
+
+ var updatedState = MessagingReducer.reduce(
+ initialState,
+ UpdateMessages(listOf(mockk())),
+ )
+
+ assertFalse(updatedState.messaging.messages.isEmpty())
+
+ updatedState = AppStoreReducer.reduce(updatedState, UpdateMessages(emptyList()))
+
+ assertTrue(updatedState.messaging.messages.isEmpty())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/nimbus/NimbusBranchesControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/nimbus/NimbusBranchesControllerTest.kt
new file mode 100644
index 0000000000..dbdfbbb5f7
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/nimbus/NimbusBranchesControllerTest.kt
@@ -0,0 +1,204 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.nimbus
+
+import android.R
+import android.app.Activity
+import android.content.Context
+import android.view.View
+import android.view.ViewGroup
+import androidx.navigation.NavController
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.verify
+import io.mockk.verifyAll
+import mozilla.components.service.nimbus.NimbusApi
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.experiments.nimbus.Branch
+import org.mozilla.fenix.components.Components
+import org.mozilla.fenix.components.FenixSnackbar
+import org.mozilla.fenix.ext.getRootView
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.nimbus.controller.NimbusBranchesController
+import org.mozilla.fenix.utils.Settings
+
+class NimbusBranchesControllerTest {
+
+ private val experiments: NimbusApi = mockk(relaxed = true)
+ private val experimentId = "id"
+
+ private lateinit var controller: NimbusBranchesController
+ private lateinit var navController: NavController
+ private lateinit var nimbusBranchesStore: NimbusBranchesStore
+ private lateinit var settings: Settings
+ private lateinit var activity: Context
+ private lateinit var components: Components
+ private lateinit var snackbar: FenixSnackbar
+ private lateinit var rootView: View
+
+ @Before
+ fun setup() {
+ components = mockk(relaxed = true)
+ settings = mockk(relaxed = true)
+ snackbar = mockk(relaxed = true)
+ navController = mockk(relaxed = true)
+
+ rootView = mockk<ViewGroup>(relaxed = true)
+ activity = mockk<Activity>(relaxed = true) {
+ every { findViewById<View>(R.id.content) } returns rootView
+ every { getRootView() } returns rootView
+ }
+
+ mockkObject(FenixSnackbar)
+ every { FenixSnackbar.make(any(), any(), any(), any()) } returns snackbar
+
+ every { activity.settings() } returns settings
+
+ every { navController.currentDestination } returns mockk {
+ every { id } returns org.mozilla.fenix.R.id.nimbusBranchesFragment
+ }
+
+ nimbusBranchesStore = NimbusBranchesStore(NimbusBranchesState(emptyList()))
+ controller = NimbusBranchesController(
+ activity,
+ navController,
+ nimbusBranchesStore,
+ experiments,
+ experimentId,
+ )
+ }
+
+ @Test
+ fun `WHEN branch item is clicked THEN branch is opted into and selectedBranch state is updated`() {
+ every { settings.isTelemetryEnabled } returns true
+ every { settings.isExperimentationEnabled } returns true
+
+ val branch = Branch(
+ slug = "slug",
+ ratio = 1,
+ )
+
+ controller.onBranchItemClicked(branch)
+
+ nimbusBranchesStore.waitUntilIdle()
+
+ verify {
+ experiments.optInWithBranch(experimentId, branch.slug)
+ }
+
+ assertEquals(branch.slug, nimbusBranchesStore.state.selectedBranch)
+ }
+
+ @Test
+ fun `WHEN branch item is clicked THEN branch is opted out and selectedBranch state is updated`() {
+ every { settings.isTelemetryEnabled } returns true
+ every { settings.isExperimentationEnabled } returns true
+ every { experiments.getExperimentBranch(experimentId) } returns "slug"
+
+ val branch = Branch(
+ slug = "slug",
+ ratio = 1,
+ )
+
+ controller.onBranchItemClicked(branch)
+
+ nimbusBranchesStore.waitUntilIdle()
+
+ verify {
+ experiments.optOut(experimentId)
+ }
+ }
+
+ @Test
+ fun `WHEN studies and telemetry are ON and item is clicked THEN branch is opted in`() {
+ every { settings.isTelemetryEnabled } returns true
+ every { settings.isExperimentationEnabled } returns true
+
+ val branch = Branch(
+ slug = "slug",
+ ratio = 1,
+ )
+
+ controller.onBranchItemClicked(branch)
+
+ nimbusBranchesStore.waitUntilIdle()
+
+ verify {
+ experiments.optInWithBranch(experimentId, branch.slug)
+ }
+
+ assertEquals(branch.slug, nimbusBranchesStore.state.selectedBranch)
+ }
+
+ @Test
+ fun `WHEN studies and telemetry are Off THEN branch is opted in AND data is not sent`() {
+ every { settings.isTelemetryEnabled } returns false
+ every { settings.isExperimentationEnabled } returns false
+ every { activity.getString(any()) } returns "hello"
+
+ val branch = Branch(
+ slug = "slug",
+ ratio = 1,
+ )
+
+ controller.onBranchItemClicked(branch)
+
+ nimbusBranchesStore.waitUntilIdle()
+
+ verifyAll {
+ experiments.getExperimentBranch(experimentId)
+ experiments.optInWithBranch(experimentId, branch.slug)
+ snackbar.setText("hello")
+ }
+
+ assertEquals(branch.slug, nimbusBranchesStore.state.selectedBranch)
+ }
+
+ @Test
+ fun `WHEN studies are ON and telemetry Off THEN branch is opted in`() {
+ every { settings.isExperimentationEnabled } returns true
+ every { settings.isTelemetryEnabled } returns false
+
+ val branch = Branch(
+ slug = "slug",
+ ratio = 1,
+ )
+
+ controller.onBranchItemClicked(branch)
+
+ nimbusBranchesStore.waitUntilIdle()
+
+ verify {
+ experiments.optInWithBranch(experimentId, branch.slug)
+ }
+
+ assertEquals(branch.slug, nimbusBranchesStore.state.selectedBranch)
+ }
+
+ @Test
+ fun `WHEN studies are OFF and telemetry ON THEN branch is opted in`() {
+ every { settings.isExperimentationEnabled } returns false
+ every { settings.isTelemetryEnabled } returns true
+
+ val branch = Branch(
+ slug = "slug",
+ ratio = 1,
+ )
+
+ controller.onBranchItemClicked(branch)
+
+ nimbusBranchesStore.waitUntilIdle()
+
+ verify {
+ experiments.optInWithBranch(experimentId, branch.slug)
+ }
+
+ assertEquals(branch.slug, nimbusBranchesStore.state.selectedBranch)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/nimbus/NimbusBranchesStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/nimbus/NimbusBranchesStoreTest.kt
new file mode 100644
index 0000000000..ea4dc94205
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/nimbus/NimbusBranchesStoreTest.kt
@@ -0,0 +1,52 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.nimbus
+
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.experiments.nimbus.Branch
+
+class NimbusBranchesStoreTest {
+
+ private lateinit var nimbusBranchesState: NimbusBranchesState
+ private lateinit var nimbusBranchesStore: NimbusBranchesStore
+
+ @Before
+ fun setup() {
+ nimbusBranchesState = NimbusBranchesState(branches = emptyList())
+ nimbusBranchesStore = NimbusBranchesStore(nimbusBranchesState)
+ }
+
+ @Test
+ fun `GIVEN a new branch and selected branch WHEN UpdateBranches action is dispatched THEN state is updated`() = runTest {
+ assertTrue(nimbusBranchesStore.state.isLoading)
+
+ val branches: List<Branch> = listOf(mockk(), mockk())
+ val selectedBranch = "control"
+
+ nimbusBranchesStore.dispatch(NimbusBranchesAction.UpdateBranches(branches, selectedBranch))
+ .join()
+
+ assertEquals(branches, nimbusBranchesStore.state.branches)
+ assertEquals(selectedBranch, nimbusBranchesStore.state.selectedBranch)
+ assertFalse(nimbusBranchesStore.state.isLoading)
+ }
+
+ @Test
+ fun `GIVEN a new selected branch WHEN UpdateSelectedBranch action is dispatched THEN selectedBranch state is updated`() = runTest {
+ assertEquals("", nimbusBranchesStore.state.selectedBranch)
+
+ val selectedBranch = "control"
+
+ nimbusBranchesStore.dispatch(NimbusBranchesAction.UpdateSelectedBranch(selectedBranch)).join()
+
+ assertEquals(selectedBranch, nimbusBranchesStore.state.selectedBranch)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/nimbus/NimbusSystemTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/nimbus/NimbusSystemTest.kt
new file mode 100644
index 0000000000..fd4ac97f1b
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/nimbus/NimbusSystemTest.kt
@@ -0,0 +1,164 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.nimbus
+
+import android.content.Context
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.runs
+import io.mockk.slot
+import mozilla.components.service.nimbus.messaging.NimbusSystem
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.experiments.nimbus.NimbusInterface
+import org.mozilla.fenix.experiments.maybeFetchExperiments
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.utils.Settings
+
+class NimbusSystemTest {
+
+ lateinit var context: Context
+ lateinit var nimbus: NimbusUnderTest
+ lateinit var settings: Settings
+
+ private val lastTimeSlot = slot<Long>()
+
+ // By default this comes from the generated Nimbus features.
+ val config = NimbusSystem(
+ refreshIntervalForeground = 60, // minutes
+ )
+
+ class NimbusUnderTest(override val context: Context) : NimbusInterface {
+ var isFetching = false
+
+ override fun fetchExperiments() {
+ isFetching = true
+ }
+ }
+
+ @Before
+ fun setUp() {
+ context = mockk(relaxed = true)
+ nimbus = NimbusUnderTest(context)
+
+ settings = mockk(relaxed = true)
+ every { context.settings() } returns settings
+
+ every { settings.nimbusLastFetchTime = capture(lastTimeSlot) } just runs
+ every { settings.nimbusLastFetchTime } returns 0L
+
+ assertFalse(nimbus.isFetching)
+ }
+
+ @Test
+ fun `GIVEN a nimbus object WHEN calling maybeFetchExperiments after an interval THEN call fetchExperiments`() {
+ val elapsedTime: Long = Settings.ONE_HOUR_MS + 1
+ nimbus.maybeFetchExperiments(
+ context,
+ config,
+ elapsedTime,
+ )
+ assertTrue(nimbus.isFetching)
+ assertEquals(elapsedTime, lastTimeSlot.captured)
+ }
+
+ @Test
+ fun `GIVEN a nimbus object WHEN calling maybeFetchExperiments at exactly an interval THEN call fetchExperiments`() {
+ val elapsedTime: Long = Settings.ONE_HOUR_MS
+ nimbus.maybeFetchExperiments(
+ context,
+ config,
+ elapsedTime,
+ )
+ assertTrue(nimbus.isFetching)
+ assertEquals(elapsedTime, lastTimeSlot.captured)
+ }
+
+ @Test
+ fun `GIVEN a nimbus object WHEN calling maybeFetchExperiments before an interval THEN do not call fetchExperiments`() {
+ val elapsedTime: Long = Settings.ONE_HOUR_MS - 1
+ nimbus.maybeFetchExperiments(
+ context,
+ config,
+ elapsedTime,
+ )
+ assertFalse(nimbus.isFetching)
+ }
+
+ @Test
+ fun `GIVEN a nimbus object WHEN calling maybeFetchExperiments at without an elapsedTime THEN call fetchExperiments`() {
+ // since elapsedTime = currentTimeMillis
+ nimbus.maybeFetchExperiments(
+ context,
+ config,
+ )
+ assertTrue(nimbus.isFetching)
+ }
+
+ @Test
+ fun `GIVEN a nimbus object calling maybeFetchExperiments WHEN using a preview collection THEN always call fetchExperiments`() {
+ var currentTime = 0L
+ fun assertFetchEveryTime() {
+ nimbus.maybeFetchExperiments(
+ context,
+ config,
+ currentTime,
+ )
+ assertTrue(nimbus.isFetching)
+ assertEquals(lastTimeSlot.captured, 0L)
+ nimbus.isFetching = false
+ }
+
+ // Using usePreview, we call fetch every time we call maybeFetch.
+ every { settings.nimbusUsePreview } returns true
+ currentTime = Settings.ONE_HOUR_MS
+ assertFetchEveryTime()
+
+ currentTime += Settings.ONE_MINUTE_MS
+ assertFetchEveryTime()
+
+ currentTime += Settings.ONE_MINUTE_MS
+ assertFetchEveryTime()
+
+ // Now turn preview collection off.
+ // We should fetch exactly once…
+ every { settings.nimbusUsePreview } returns false
+
+ currentTime += Settings.ONE_MINUTE_MS
+ nimbus.maybeFetchExperiments(
+ context,
+ config,
+ currentTime,
+ )
+ assertTrue(nimbus.isFetching)
+ assertEquals(lastTimeSlot.captured, currentTime)
+ nimbus.isFetching = false
+ every { settings.nimbusLastFetchTime } returns currentTime
+
+ // … and then back off. We show here that the next call to maybeFetch
+ // doesn't call fetch.
+ currentTime += Settings.ONE_MINUTE_MS
+ nimbus.maybeFetchExperiments(
+ context,
+ config,
+ currentTime,
+ )
+ assertFalse(nimbus.isFetching)
+
+ // Now wait, another hour, and we've reset the behaviour back to normal operation.
+ currentTime += Settings.ONE_HOUR_MS + Settings.ONE_MINUTE_MS
+ nimbus.maybeFetchExperiments(
+ context,
+ config,
+ currentTime,
+ )
+ assertTrue(nimbus.isFetching)
+ assertEquals(lastTimeSlot.captured, currentTime)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/onboarding/FenixOnboardingTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/onboarding/FenixOnboardingTest.kt
new file mode 100644
index 0000000000..e8ba32f204
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/onboarding/FenixOnboardingTest.kt
@@ -0,0 +1,71 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.onboarding
+
+import android.content.Context
+import android.content.Context.MODE_PRIVATE
+import android.content.SharedPreferences
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.helpers.perf.TestStrictModeManager
+import org.mozilla.fenix.onboarding.FenixOnboarding.Companion.CURRENT_ONBOARDING_VERSION
+import org.mozilla.fenix.onboarding.FenixOnboarding.Companion.LAST_VERSION_ONBOARDING_KEY
+import org.mozilla.fenix.perf.StrictModeManager
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class FenixOnboardingTest {
+
+ private lateinit var onboarding: FenixOnboarding
+ private lateinit var preferences: SharedPreferences
+ private lateinit var preferencesEditor: SharedPreferences.Editor
+ private lateinit var settings: Settings
+
+ @Before
+ fun setup() {
+ preferences = mockk()
+ preferencesEditor = mockk(relaxed = true)
+ settings = mockk(relaxed = true)
+ val context = mockk<Context>()
+ every { preferences.edit() } returns preferencesEditor
+ every { context.components.strictMode } returns TestStrictModeManager() as StrictModeManager
+ every { context.getSharedPreferences(any(), MODE_PRIVATE) } returns preferences
+ every { context.settings() } returns settings
+
+ onboarding = FenixOnboarding(context)
+ }
+
+ @Test
+ fun testUserHasBeenOnboarded() {
+ every {
+ preferences.getInt(LAST_VERSION_ONBOARDING_KEY, any())
+ } returns 0
+ assertFalse(onboarding.userHasBeenOnboarded())
+
+ every {
+ preferences.getInt(LAST_VERSION_ONBOARDING_KEY, any())
+ } returns CURRENT_ONBOARDING_VERSION
+ assertTrue(onboarding.userHasBeenOnboarded())
+ }
+
+ @Test
+ fun testFinish() {
+ settings.showHomeOnboardingDialog = true
+
+ onboarding.finish()
+
+ assertFalse(settings.showHomeOnboardingDialog)
+ verify { preferencesEditor.putInt(LAST_VERSION_ONBOARDING_KEY, CURRENT_ONBOARDING_VERSION) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/onboarding/ReEngagementNotificationWorkerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/onboarding/ReEngagementNotificationWorkerTest.kt
new file mode 100644
index 0000000000..4699f0e7ce
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/onboarding/ReEngagementNotificationWorkerTest.kt
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.onboarding
+
+import junit.framework.TestCase.assertFalse
+import junit.framework.TestCase.assertTrue
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class ReEngagementNotificationWorkerTest {
+ lateinit var settings: Settings
+
+ @Before
+ fun setUp() {
+ settings = Settings(testContext)
+ }
+
+ @Test
+ fun `GIVEN last browser activity THEN determine if the user is active correctly`() {
+ val now = System.currentTimeMillis()
+ val fourHoursAgo = now - Settings.FOUR_HOURS_MS
+ val oneDayAgo = now - Settings.ONE_DAY_MS
+
+ assertTrue(ReEngagementNotificationWorker.isActiveUser(now, now))
+ assertTrue(ReEngagementNotificationWorker.isActiveUser(fourHoursAgo, now))
+
+ // test inactive user threshold
+ assertTrue(ReEngagementNotificationWorker.isActiveUser(oneDayAgo, now))
+ assertFalse(ReEngagementNotificationWorker.isActiveUser(oneDayAgo - 1, now))
+ assertTrue(ReEngagementNotificationWorker.isActiveUser(oneDayAgo + 1, now))
+
+ // test default value
+ assertFalse(ReEngagementNotificationWorker.isActiveUser(0, now))
+
+ // test unlikely value
+ assertFalse(ReEngagementNotificationWorker.isActiveUser(-1000, now))
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/onboarding/view/OnboardingMapperTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/onboarding/view/OnboardingMapperTest.kt
new file mode 100644
index 0000000000..4e903157b4
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/onboarding/view/OnboardingMapperTest.kt
@@ -0,0 +1,156 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.onboarding.view
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.mozilla.fenix.R
+
+class OnboardingMapperTest {
+
+ @Test
+ fun `GIVEN a default browser page WHEN mapToOnboardingPageState is called THEN creates the expected OnboardingPageState`() {
+ val expected = OnboardingPageState(
+ imageRes = R.drawable.ic_onboarding_welcome,
+ title = "default browser title",
+ description = "default browser body with link text",
+ primaryButton = Action("default browser primary button text", unitLambda),
+ secondaryButton = Action("default browser secondary button text", unitLambda),
+ )
+
+ val onboardingPageUiData = OnboardingPageUiData(
+ type = OnboardingPageUiData.Type.DEFAULT_BROWSER,
+ imageRes = R.drawable.ic_onboarding_welcome,
+ title = "default browser title",
+ description = "default browser body with link text",
+ primaryButtonLabel = "default browser primary button text",
+ secondaryButtonLabel = "default browser secondary button text",
+ privacyCaption = null,
+ )
+ val actual = mapToOnboardingPageState(
+ onboardingPageUiData = onboardingPageUiData,
+ onMakeFirefoxDefaultClick = unitLambda,
+ onMakeFirefoxDefaultSkipClick = unitLambda,
+ onSignInButtonClick = {},
+ onSignInSkipClick = {},
+ onNotificationPermissionButtonClick = {},
+ onNotificationPermissionSkipClick = {},
+ onAddFirefoxWidgetClick = {},
+ onAddFirefoxWidgetSkipClick = {},
+ )
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `GIVEN a sync page WHEN mapToOnboardingPageState is called THEN creates the expected OnboardingPageState`() {
+ val expected = OnboardingPageState(
+ imageRes = R.drawable.ic_onboarding_sync,
+ title = "sync title",
+ description = "sync body",
+ primaryButton = Action("sync primary button text", unitLambda),
+ secondaryButton = Action("sync secondary button text", unitLambda),
+ )
+
+ val onboardingPageUiData = OnboardingPageUiData(
+ type = OnboardingPageUiData.Type.SYNC_SIGN_IN,
+ imageRes = R.drawable.ic_onboarding_sync,
+ title = "sync title",
+ description = "sync body",
+ primaryButtonLabel = "sync primary button text",
+ secondaryButtonLabel = "sync secondary button text",
+ privacyCaption = null,
+ )
+ val actual = mapToOnboardingPageState(
+ onboardingPageUiData = onboardingPageUiData,
+ onMakeFirefoxDefaultClick = {},
+ onMakeFirefoxDefaultSkipClick = {},
+ onSignInButtonClick = unitLambda,
+ onSignInSkipClick = unitLambda,
+ onNotificationPermissionButtonClick = {},
+ onNotificationPermissionSkipClick = {},
+ onAddFirefoxWidgetClick = {},
+ onAddFirefoxWidgetSkipClick = {},
+ )
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `GIVEN a notification page WHEN mapToOnboardingPageState is called THEN creates the expected OnboardingPageState`() {
+ val expected = OnboardingPageState(
+ imageRes = R.drawable.ic_notification_permission,
+ title = "notification title",
+ description = "notification body",
+ primaryButton = Action("notification primary button text", unitLambda),
+ secondaryButton = Action("notification secondary button text", unitLambda),
+ )
+
+ val onboardingPageUiData = OnboardingPageUiData(
+ type = OnboardingPageUiData.Type.NOTIFICATION_PERMISSION,
+ imageRes = R.drawable.ic_notification_permission,
+ title = "notification title",
+ description = "notification body",
+ primaryButtonLabel = "notification primary button text",
+ secondaryButtonLabel = "notification secondary button text",
+ privacyCaption = null,
+ )
+ val actual = mapToOnboardingPageState(
+ onboardingPageUiData = onboardingPageUiData,
+ onMakeFirefoxDefaultClick = {},
+ onMakeFirefoxDefaultSkipClick = {},
+ onSignInButtonClick = {},
+ onSignInSkipClick = {},
+ onNotificationPermissionButtonClick = unitLambda,
+ onNotificationPermissionSkipClick = unitLambda,
+ onAddFirefoxWidgetClick = {},
+ onAddFirefoxWidgetSkipClick = {},
+ )
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `GIVEN an add search widget page WHEN mapToOnboardingPageState is called THEN creates the expected OnboardingPageState`() {
+ val expected = OnboardingPageState(
+ imageRes = R.drawable.ic_onboarding_search_widget,
+ title = "add search widget title",
+ description = "add search widget body with link text",
+ primaryButton = Action("add search widget primary button text", unitLambda),
+ secondaryButton = Action("add search widget secondary button text", unitLambda),
+ )
+
+ val onboardingPageUiData = OnboardingPageUiData(
+ type = OnboardingPageUiData.Type.ADD_SEARCH_WIDGET,
+ imageRes = R.drawable.ic_onboarding_search_widget,
+ title = "add search widget title",
+ description = "add search widget body with link text",
+ primaryButtonLabel = "add search widget primary button text",
+ secondaryButtonLabel = "add search widget secondary button text",
+ privacyCaption = null,
+ )
+ val actual = mapToOnboardingPageState(
+ onboardingPageUiData = onboardingPageUiData,
+ onMakeFirefoxDefaultClick = {},
+ onMakeFirefoxDefaultSkipClick = {},
+ onSignInButtonClick = {},
+ onSignInSkipClick = {},
+ onNotificationPermissionButtonClick = {},
+ onNotificationPermissionSkipClick = {},
+ onAddFirefoxWidgetClick = unitLambda,
+ onAddFirefoxWidgetSkipClick = unitLambda,
+ )
+
+ assertEquals(expected, actual)
+ }
+}
+
+private val unitLambda = { dummyUnitFunc() }
+
+private fun dummyUnitFunc() {}
+
+private fun dummyStringArgFunc(string: String) {
+ print(string)
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/onboarding/view/OnboardingPageUiDataTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/onboarding/view/OnboardingPageUiDataTest.kt
new file mode 100644
index 0000000000..ef06b4f3c7
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/onboarding/view/OnboardingPageUiDataTest.kt
@@ -0,0 +1,88 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.onboarding.view
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.mozilla.fenix.R
+
+class OnboardingPageUiDataTest {
+
+ @Test
+ fun `GIVEN first page in the list WHEN sequencePosition called THEN returns the index plus 1`() {
+ val expected = "1"
+ val actual = allKnownPages.sequencePosition(defaultBrowserPageUiData.type)
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `GIVEN last page in the list WHEN sequencePosition called THEN returns the index plus 1`() {
+ val expected = "3"
+ val actual = allKnownPages.sequencePosition(notificationPageUiData.type)
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `GIVEN all known pages of WHEN sequenceId() called THEN should map to the correct sequence id`() {
+ val expected = "default_sync_notification"
+ val actual = allKnownPages.telemetrySequenceId()
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `GIVEN some of the known pages WHEN sequenceId() called THEN should map to the correct sequence id`() {
+ val expected = "default_sync"
+ val actual = listOf(defaultBrowserPageUiData, syncPageUiData).telemetrySequenceId()
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `GIVEN a single known page WHEN sequenceId() called THEN should map to the correct sequence id`() {
+ val expected = "default"
+ val actual = listOf(defaultBrowserPageUiData).telemetrySequenceId()
+
+ assertEquals(expected, actual)
+ }
+}
+
+private val defaultBrowserPageUiData = OnboardingPageUiData(
+ type = OnboardingPageUiData.Type.DEFAULT_BROWSER,
+ imageRes = R.drawable.ic_onboarding_welcome,
+ title = "default browser title",
+ description = "default browser body with link text",
+ primaryButtonLabel = "default browser primary button text",
+ secondaryButtonLabel = "default browser secondary button text",
+ privacyCaption = null,
+)
+
+private val syncPageUiData = OnboardingPageUiData(
+ type = OnboardingPageUiData.Type.SYNC_SIGN_IN,
+ imageRes = R.drawable.ic_onboarding_sync,
+ title = "sync title",
+ description = "sync body",
+ primaryButtonLabel = "sync primary button text",
+ secondaryButtonLabel = "sync secondary button text",
+ privacyCaption = null,
+)
+
+private val notificationPageUiData = OnboardingPageUiData(
+ type = OnboardingPageUiData.Type.NOTIFICATION_PERMISSION,
+ imageRes = R.drawable.ic_notification_permission,
+ title = "notification title",
+ description = "notification body",
+ primaryButtonLabel = "notification primary button text",
+ secondaryButtonLabel = "notification secondary button text",
+ privacyCaption = null,
+)
+
+private val allKnownPages = listOf(
+ defaultBrowserPageUiData,
+ syncPageUiData,
+ notificationPageUiData,
+)
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/PerformanceInflaterTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/PerformanceInflaterTest.kt
new file mode 100644
index 0000000000..7530951a89
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/PerformanceInflaterTest.kt
@@ -0,0 +1,102 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.perf
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.FrameLayout
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import java.io.File
+
+@RunWith(FenixRobolectricTestRunner::class)
+class PerformanceInflaterTest {
+
+ private lateinit var perfInflater: MockInflater
+
+ private val layoutsNotToTest = setOf(
+ "fragment_browser",
+ "fragment_add_on_internal_settings",
+ "activity_privacy_content_display",
+ /**
+ * activity_home.xml contains FragmentContainerView which needs to be
+ * put inside FragmentActivity in order to get inflated
+ */
+ "activity_home",
+ )
+
+ @Before
+ fun setup() {
+ InflationCounter.inflationCount.set(0)
+
+ every { testContext.components.core.engine.profiler } returns mockk(relaxed = true)
+ perfInflater = MockInflater(LayoutInflater.from(testContext), testContext)
+ }
+
+ @Test
+ fun `WHEN we inflate a view,THEN the inflation counter should increase`() {
+ assertEquals(0, InflationCounter.inflationCount.get())
+ perfInflater.inflate(R.layout.fragment_home, null, false)
+ assertEquals(1, InflationCounter.inflationCount.get())
+ }
+
+ @Test
+ fun `WHEN inflating one of our resource file, the inflater should not crash`() {
+ val fileList = File("./src/main/res/layout").listFiles()
+
+ // There might be custom views who try to access `Settings` through the extension function.
+ mockkStatic("org.mozilla.fenix.ext.ContextKt") {
+ every { any<Context>().settings() } returns mockk(relaxed = true)
+
+ for (file in fileList!!) {
+ val layoutName = file.name.split(".")[0]
+ val layoutId = testContext.resources.getIdentifier(
+ layoutName,
+ "layout",
+ testContext.packageName,
+ )
+
+ assertNotEquals(-1, layoutId)
+ if (!layoutsNotToTest.contains(layoutName)) {
+ perfInflater.inflate(layoutId, FrameLayout(testContext), true)
+ }
+ }
+ }
+ }
+}
+
+private class MockInflater(
+ inflater: LayoutInflater,
+ context: Context,
+) : PerformanceInflater(
+ inflater,
+ context,
+) {
+
+ override fun onCreateView(name: String?, attrs: AttributeSet?): View? {
+ // We skip the fragment layout for the simple reason that it implements
+ // a whole different inflate which is implemented in the activity.LayoutFactory
+ // methods. To be able to properly test it here, we would have to copy the whole
+ // inflater file (or create an activity) and pass our layout through the onCreateView
+ // method of that activity.
+ if (name!!.contains("fragment")) {
+ return FrameLayout(testContext)
+ }
+ return super.onCreateView(name, attrs)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/ProfilerMarkerFactProcessorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/ProfilerMarkerFactProcessorTest.kt
new file mode 100644
index 0000000000..f33d6de318
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/ProfilerMarkerFactProcessorTest.kt
@@ -0,0 +1,92 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.perf
+
+import android.os.Handler
+import android.os.Looper
+import io.mockk.Called
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.RelaxedMockK
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import mozilla.components.concept.base.profiler.Profiler
+import mozilla.components.support.base.Component
+import mozilla.components.support.base.facts.Action
+import mozilla.components.support.base.facts.Fact
+import org.junit.Before
+import org.junit.Test
+
+class ProfilerMarkerFactProcessorTest {
+
+ @RelaxedMockK lateinit var profiler: Profiler
+
+ @RelaxedMockK lateinit var mainHandler: Handler
+ lateinit var processor: ProfilerMarkerFactProcessor
+
+ var myLooper: Looper? = null
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+ myLooper = null
+ processor = ProfilerMarkerFactProcessor({ profiler }, mainHandler, { myLooper })
+ }
+
+ @Test
+ fun `GIVEN we are on the main thread WHEN a fact with an implementation detail action is received THEN a profiler marker is added now`() {
+ myLooper = mainHandler.looper // main thread
+
+ val fact = newFact(Action.IMPLEMENTATION_DETAIL, value = "testValue")
+ processor.process(fact)
+
+ verify { profiler.addMarker(fact.item, fact.value) }
+ }
+
+ @Test
+ fun `GIVEN we are not on the main thread WHEN a fact with an implementation detail action is received THEN adding the marker is posted to the main thread`() {
+ myLooper = mockk() // off main thread
+ val mainThreadPostedSlot = slot<Runnable>()
+ every { profiler.getProfilerTime() } returns 100.0
+
+ val fact = newFact(Action.IMPLEMENTATION_DETAIL, value = "testValue")
+ processor.process(fact)
+
+ verify { mainHandler.post(capture(mainThreadPostedSlot)) }
+ verifyProfilerAddMarkerWasNotCalled()
+
+ mainThreadPostedSlot.captured.run() // call the captured function posted to the main thread.
+ verify { profiler.addMarker(fact.item, 100.0, 100.0, fact.value) }
+ }
+
+ @Test
+ fun `WHEN a fact with a non-implementation detail action is received THEN no profiler marker is added`() {
+ val fact = newFact(Action.CANCEL)
+ processor.process(fact)
+ verify { profiler wasNot Called }
+ }
+
+ private fun verifyProfilerAddMarkerWasNotCalled() {
+ verify(exactly = 0) {
+ profiler.addMarker(any())
+ profiler.addMarker(any(), any() as Double?)
+ profiler.addMarker(any(), any() as String?)
+ profiler.addMarker(any(), any(), any())
+ profiler.addMarker(any(), any(), any(), any())
+ }
+ }
+}
+
+private fun newFact(
+ action: Action,
+ item: String = "itemName",
+ value: String? = null,
+) = Fact(
+ Component.BROWSER_STATE,
+ action,
+ item,
+ value,
+)
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/RunBlockingCounterTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/RunBlockingCounterTest.kt
new file mode 100644
index 0000000000..65604d885d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/RunBlockingCounterTest.kt
@@ -0,0 +1,24 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.perf
+
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+
+class RunBlockingCounterTest {
+
+ @Before
+ fun setup() {
+ RunBlockingCounter.count.set(0)
+ }
+
+ @Test
+ fun `GIVEN we call our custom runBlocking method with counter THEN the latter should increase`() {
+ assertEquals(0, RunBlockingCounter.count.get())
+ runBlockingIncrement {}
+ assertEquals(1, RunBlockingCounter.count.get())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StartupActivityLogTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StartupActivityLogTest.kt
new file mode 100644
index 0000000000..c0f598833f
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StartupActivityLogTest.kt
@@ -0,0 +1,115 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.perf
+
+import android.app.Activity
+import android.app.Application
+import androidx.lifecycle.LifecycleOwner
+import io.mockk.Called
+import io.mockk.MockKAnnotations
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.support.base.log.Log.Priority
+import mozilla.components.support.base.log.logger.Logger
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.perf.StartupActivityLog.LogEntry
+
+class StartupActivityLogTest {
+
+ private lateinit var log: StartupActivityLog
+ private lateinit var appObserver: StartupActivityLog.StartupLogAppLifecycleObserver
+ private lateinit var activityCallbacks: StartupActivityLog.StartupLogActivityLifecycleCallbacks
+
+ @MockK(relaxed = true)
+ private lateinit var logger: Logger
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+
+ log = StartupActivityLog()
+ val (appObserver, activityCallbacks) = log.getObserversForTesting()
+ this.appObserver = appObserver
+ this.activityCallbacks = activityCallbacks
+ }
+
+ @Test
+ fun `WHEN register is called THEN it is registered`() {
+ val app = mockk<Application>(relaxed = true)
+ val lifecycleOwner = mockk<LifecycleOwner>(relaxed = true)
+ log.registerInAppOnCreate(app, lifecycleOwner)
+
+ verify { app.registerActivityLifecycleCallbacks(any()) }
+ verify { lifecycleOwner.lifecycle.addObserver(any()) }
+ }
+
+ @Test // we test start and stop individually due to the clear-on-stop behavior.
+ fun `WHEN app observer start is called THEN it is added directly to the log`() {
+ assertTrue(log.log.isEmpty())
+
+ appObserver.onStart(mockk())
+ assertEquals(listOf(LogEntry.AppStarted), log.log)
+
+ appObserver.onStart(mockk())
+ assertEquals(listOf(LogEntry.AppStarted, LogEntry.AppStarted), log.log)
+ }
+
+ @Test // we test start and stop individually due to the clear-on-stop behavior.
+ fun `WHEN app observer stop is called THEN it is added directly to the log`() {
+ assertTrue(log.log.isEmpty())
+
+ appObserver.onStop(mockk())
+ assertEquals(listOf(LogEntry.AppStopped), log.log)
+ }
+
+ @Test
+ fun `WHEN activity callback methods are called THEN they are added directly to the log`() {
+ assertTrue(log.log.isEmpty())
+ val expected = mutableListOf<LogEntry>()
+
+ val activityClass = mockk<Activity>()::class.java // mockk can't mock Class<...>
+
+ activityCallbacks.onActivityCreated(mockk(), null)
+ expected.add(LogEntry.ActivityCreated(activityClass))
+ assertEquals(expected, log.log)
+
+ activityCallbacks.onActivityStarted(mockk())
+ expected.add(LogEntry.ActivityStarted(activityClass))
+ assertEquals(expected, log.log)
+
+ activityCallbacks.onActivityStopped(mockk())
+ expected.add(LogEntry.ActivityStopped(activityClass))
+ assertEquals(expected, log.log)
+ }
+
+ @Test
+ fun `WHEN app STOPPED is called THEN the log is emptied expect for the stop event`() {
+ assertTrue(log.log.isEmpty())
+
+ activityCallbacks.onActivityCreated(mockk(), null)
+ activityCallbacks.onActivityStarted(mockk())
+ appObserver.onStart(mockk())
+ assertEquals(3, log.log.size)
+
+ appObserver.onStop(mockk())
+ assertEquals(listOf(LogEntry.AppStopped), log.log)
+ }
+
+ @Test
+ fun `GIVEN debug log level WHEN logEntries is called THEN there is no logcat call`() {
+ log.logEntries(logger, Priority.DEBUG)
+ verify { logger.debug(any()) }
+ }
+
+ @Test
+ fun `GIVEN info log level WHEN logEntries is called THEN there is a logcat call`() {
+ log.logEntries(logger, Priority.INFO)
+ verify { logger wasNot Called }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StartupPathProviderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StartupPathProviderTest.kt
new file mode 100644
index 0000000000..ad14ebc0c1
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StartupPathProviderTest.kt
@@ -0,0 +1,203 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.perf
+
+import android.content.Intent
+import androidx.lifecycle.Lifecycle
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.perf.StartupPathProvider.StartupPath
+
+class StartupPathProviderTest {
+
+ private lateinit var provider: StartupPathProvider
+ private lateinit var callbacks: StartupPathProvider.StartupPathLifecycleObserver
+
+ @MockK private lateinit var intent: Intent
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+
+ provider = StartupPathProvider()
+ callbacks = provider.getTestCallbacks()
+ }
+
+ @Test
+ fun `WHEN attach is called THEN the provider is registered to the lifecycle`() {
+ val lifecycle = mockk<Lifecycle>(relaxed = true)
+ provider.attachOnActivityOnCreate(lifecycle, null)
+
+ verify { lifecycle.addObserver(any()) }
+ }
+
+ @Test
+ fun `WHEN calling attach THEN the intent is passed to on intent received`() {
+ // With this test, we're basically saying, "attach..." does the same thing as
+ // "onIntentReceived" so we don't need to duplicate all the tests we run for
+ // "onIntentReceived".
+ val spyProvider = spyk(provider)
+ every { spyProvider.onIntentReceived(intent) } returns Unit
+ spyProvider.attachOnActivityOnCreate(mockk(relaxed = true), intent)
+
+ verify { spyProvider.onIntentReceived(intent) }
+ }
+
+ @Test
+ fun `GIVEN no intent is received and the activity is not started WHEN getting the start up path THEN it is not set`() {
+ assertEquals(StartupPath.NOT_SET, provider.startupPathForActivity)
+ }
+
+ @Test
+ fun `GIVEN a main intent is received but the activity is not started yet WHEN getting the start up path THEN main is returned`() {
+ every { intent.action } returns Intent.ACTION_MAIN
+ provider.onIntentReceived(intent)
+ assertEquals(StartupPath.MAIN, provider.startupPathForActivity)
+ }
+
+ @Test
+ fun `GIVEN a main intent is received and the app is started WHEN getting the start up path THEN it is main`() {
+ every { intent.action } returns Intent.ACTION_MAIN
+ callbacks.onCreate(mockk())
+ provider.onIntentReceived(intent)
+ callbacks.onStart(mockk())
+
+ assertEquals(StartupPath.MAIN, provider.startupPathForActivity)
+ }
+
+ @Test
+ fun `GIVEN the app is launched from the homescreen WHEN getting the start up path THEN it is main`() {
+ // There's technically more to a homescreen Intent but it's fine for now.
+ every { intent.action } returns Intent.ACTION_MAIN
+ launchApp(intent)
+ assertEquals(StartupPath.MAIN, provider.startupPathForActivity)
+ }
+
+ @Test
+ fun `GIVEN the app is launched by app link WHEN getting the start up path THEN it is view`() {
+ // There's technically more to a homescreen Intent but it's fine for now.
+ every { intent.action } returns Intent.ACTION_VIEW
+ launchApp(intent)
+ assertEquals(StartupPath.VIEW, provider.startupPathForActivity)
+ }
+
+ @Test
+ fun `GIVEN the app is launched by a send action WHEN getting the start up path THEN it is unknown`() {
+ every { intent.action } returns Intent.ACTION_SEND
+ launchApp(intent)
+ assertEquals(StartupPath.UNKNOWN, provider.startupPathForActivity)
+ }
+
+ @Test
+ fun `GIVEN the app is launched by a null intent (is this possible) WHEN getting the start up path THEN it is not set`() {
+ callbacks.onCreate(mockk())
+ provider.onIntentReceived(null)
+ callbacks.onStart(mockk())
+ callbacks.onResume(mockk())
+
+ assertEquals(StartupPath.NOT_SET, provider.startupPathForActivity)
+ }
+
+ @Test
+ fun `GIVEN the app is launched to the homescreen with MAIN and stopped WHEN getting the start up path THEN it set to MAIN`() {
+ every { intent.action } returns Intent.ACTION_MAIN
+ launchApp(intent)
+ stopLaunchedApp()
+
+ assertEquals(StartupPath.MAIN, provider.startupPathForActivity)
+ }
+
+ @Test
+ fun `GIVEN the app is launched to the homescreen, stopped, and relaunched warm from app link WHEN getting the start up path THEN it is view`() {
+ every { intent.action } returns Intent.ACTION_MAIN
+ launchApp(intent)
+ stopLaunchedApp()
+
+ every { intent.action } returns Intent.ACTION_VIEW
+ startStoppedApp(intent)
+
+ assertEquals(StartupPath.VIEW, provider.startupPathForActivity)
+ }
+
+ @Test
+ fun `GIVEN the app is launched to the homescreen with MAIN, stopped, and relaunched warm from the app switcher WHEN getting the start up path THEN it set to MAIN'`() {
+ every { intent.action } returns Intent.ACTION_MAIN
+ launchApp(intent)
+ stopLaunchedApp()
+ startStoppedAppFromAppSwitcher()
+
+ assertEquals(StartupPath.MAIN, provider.startupPathForActivity)
+ }
+
+ @Test
+ fun `GIVEN the app is launched to the homescreen, paused, and resumed WHEN getting the start up path THEN it returns the initial intent value`() {
+ every { intent.action } returns Intent.ACTION_MAIN
+ launchApp(intent)
+ callbacks.onPause(mockk())
+ callbacks.onResume(mockk())
+
+ assertEquals(StartupPath.MAIN, provider.startupPathForActivity)
+ }
+
+ @Test
+ fun `GIVEN the app is launched with an intent and receives an intent while the activity is foregrounded WHEN getting the start up path THEN it returns the initial intent value`() {
+ every { intent.action } returns Intent.ACTION_MAIN
+ launchApp(intent)
+ every { intent.action } returns Intent.ACTION_VIEW
+ receiveIntentInForeground(intent)
+
+ assertEquals(StartupPath.MAIN, provider.startupPathForActivity)
+ }
+
+ @Test
+ fun `GIVEN the app is launched with MAIN, stopped, started from the app switcher and receives an intent in the foreground WHEN getting the start up path THEN it returns MAIN`() {
+ every { intent.action } returns Intent.ACTION_MAIN
+ launchApp(intent)
+ stopLaunchedApp()
+ startStoppedAppFromAppSwitcher()
+ every { intent.action } returns Intent.ACTION_VIEW
+ receiveIntentInForeground(intent)
+
+ assertEquals(StartupPath.MAIN, provider.startupPathForActivity)
+ }
+
+ private fun launchApp(intent: Intent) {
+ callbacks.onCreate(mockk())
+ provider.onIntentReceived(intent)
+ callbacks.onStart(mockk())
+ callbacks.onResume(mockk())
+ }
+
+ private fun stopLaunchedApp() {
+ callbacks.onPause(mockk())
+ callbacks.onStop(mockk())
+ }
+
+ private fun startStoppedApp(intent: Intent) {
+ callbacks.onStart(mockk())
+ provider.onIntentReceived(intent)
+ callbacks.onResume(mockk())
+ }
+
+ private fun startStoppedAppFromAppSwitcher() {
+ // What makes the app switcher case special is it starts the app without an intent.
+ callbacks.onStart(mockk())
+ callbacks.onResume(mockk())
+ }
+
+ private fun receiveIntentInForeground(intent: Intent) {
+ // To my surprise, the app is paused before receiving an intent on Pixel 2.
+ callbacks.onPause(mockk())
+ provider.onIntentReceived(intent)
+ callbacks.onResume(mockk())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StartupReportFullyDrawnTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StartupReportFullyDrawnTest.kt
new file mode 100644
index 0000000000..dc3d981267
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StartupReportFullyDrawnTest.kt
@@ -0,0 +1,96 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.perf
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewTreeObserver
+import android.widget.LinearLayout
+import io.mockk.Called
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.R
+import org.mozilla.fenix.databinding.TopSiteItemBinding
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.home.topsites.TopSiteItemViewHolder
+import org.mozilla.fenix.perf.StartupTimelineStateMachine.StartupDestination
+import org.mozilla.fenix.perf.StartupTimelineStateMachine.StartupState
+
+@RunWith(FenixRobolectricTestRunner::class)
+class StartupReportFullyDrawnTest {
+
+ @MockK private lateinit var activity: HomeActivity
+ private lateinit var holder: TopSiteItemViewHolder
+
+ @MockK(relaxed = true)
+ private lateinit var rootContainer: LinearLayout
+
+ @MockK(relaxed = true)
+ private lateinit var holderItemView: View
+
+ @MockK(relaxed = true)
+ private lateinit var viewTreeObserver: ViewTreeObserver
+ private lateinit var fullyDrawn: StartupReportFullyDrawn
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ val binding = TopSiteItemBinding.inflate(LayoutInflater.from(testContext), rootContainer, false)
+ holderItemView = spyk(binding.root)
+ every { activity.findViewById<LinearLayout>(R.id.rootContainer) } returns rootContainer
+ every { holderItemView.context } returns activity
+ holder = TopSiteItemViewHolder(holderItemView, mockk(), mockk(), mockk())
+ every { rootContainer.viewTreeObserver } returns viewTreeObserver
+ every { holderItemView.viewTreeObserver } returns viewTreeObserver
+
+ fullyDrawn = StartupReportFullyDrawn()
+ }
+
+ @Test
+ fun testOnActivityCreateEndHome() {
+ // Only APP_LINK destination
+ fullyDrawn.onActivityCreateEndHome(StartupState.Cold(StartupDestination.UNKNOWN), activity)
+ fullyDrawn.onActivityCreateEndHome(StartupState.Cold(StartupDestination.HOMESCREEN), activity)
+ verify { activity wasNot Called }
+
+ // Only run once
+ fullyDrawn.onActivityCreateEndHome(StartupState.Cold(StartupDestination.APP_LINK), activity)
+ verify(exactly = 1) { activity.findViewById<LinearLayout>(R.id.rootContainer) }
+
+ fullyDrawn.onActivityCreateEndHome(StartupState.Cold(StartupDestination.APP_LINK), activity)
+ verify(exactly = 1) { activity.findViewById<LinearLayout>(R.id.rootContainer) }
+
+ every { activity.reportFullyDrawn() } just Runs
+ triggerPreDraw()
+ verify { activity.reportFullyDrawn() }
+ }
+
+ @Test
+ fun testOnTopSitesItemBound() {
+ fullyDrawn.onTopSitesItemBound(StartupState.Cold(StartupDestination.HOMESCREEN), holder)
+
+ every { activity.reportFullyDrawn() } just Runs
+ triggerPreDraw()
+ verify { activity.reportFullyDrawn() }
+ }
+
+ private fun triggerPreDraw() {
+ val listener = slot<ViewTreeObserver.OnPreDrawListener>()
+ verify { viewTreeObserver.addOnPreDrawListener(capture(listener)) }
+ listener.captured.onPreDraw()
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StartupStateProviderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StartupStateProviderTest.kt
new file mode 100644
index 0000000000..af58578e67
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StartupStateProviderTest.kt
@@ -0,0 +1,431 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.perf
+
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.IntentReceiverActivity
+import org.mozilla.fenix.perf.AppStartReasonProvider.StartReason
+import org.mozilla.fenix.perf.StartupActivityLog.LogEntry
+import org.mozilla.fenix.perf.StartupStateProvider.StartupState
+
+class StartupStateProviderTest {
+
+ private lateinit var provider: StartupStateProvider
+
+ @MockK private lateinit var startupActivityLog: StartupActivityLog
+
+ @MockK private lateinit var startReasonProvider: AppStartReasonProvider
+
+ private lateinit var logEntries: MutableList<LogEntry>
+
+ private val homeActivityClass = HomeActivity::class.java
+ private val irActivityClass = IntentReceiverActivity::class.java
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+
+ provider = StartupStateProvider(startupActivityLog, startReasonProvider)
+
+ logEntries = mutableListOf()
+ every { startupActivityLog.log } returns logEntries
+
+ every { startReasonProvider.reason } returns StartReason.ACTIVITY // default to minimize repetition.
+ }
+
+ @Test
+ fun `GIVEN the app started for an activity WHEN is cold start THEN cold start is true`() {
+ forEachColdStartEntries { index ->
+ assertTrue("$index", provider.isColdStartForStartedActivity(homeActivityClass))
+ }
+ }
+
+ @Test
+ fun `GIVEN the app started for an activity WHEN warm start THEN cold start is false`() {
+ forEachWarmStartEntries { index ->
+ assertFalse("$index", provider.isColdStartForStartedActivity(homeActivityClass))
+ }
+ }
+
+ @Test
+ fun `GIVEN the app started for an activity WHEN hot start THEN cold start is false`() {
+ forEachHotStartEntries { index ->
+ assertFalse("$index", provider.isColdStartForStartedActivity(homeActivityClass))
+ }
+ }
+
+ @Test
+ fun `GIVEN the app started for an activity WHEN is cold start THEN warm start is false`() {
+ forEachColdStartEntries { index ->
+ assertFalse("$index", provider.isWarmStartForStartedActivity(homeActivityClass))
+ }
+ }
+
+ @Test
+ fun `GIVEN the app started for an activity WHEN is warm start THEN warm start is true`() {
+ forEachWarmStartEntries { index ->
+ assertTrue("$index", provider.isWarmStartForStartedActivity(homeActivityClass))
+ }
+ }
+
+ @Test
+ fun `GIVEN the app started for an activity WHEN is hot start THEN warm start is false`() {
+ forEachHotStartEntries { index ->
+ assertFalse("$index", provider.isWarmStartForStartedActivity(homeActivityClass))
+ }
+ }
+
+ @Test
+ fun `GIVEN the app started for an activity WHEN is cold start THEN hot start is false`() {
+ forEachColdStartEntries { index ->
+ assertFalse("$index", provider.isHotStartForStartedActivity(homeActivityClass))
+ }
+ }
+
+ @Test
+ fun `GIVEN the app started for an activity WHEN is warm start THEN hot start is false`() {
+ forEachWarmStartEntries { index ->
+ assertFalse("$index", provider.isHotStartForStartedActivity(homeActivityClass))
+ }
+ }
+
+ @Test
+ fun `GIVEN the app started for an activity WHEN is hot start THEN hot start is true`() {
+ forEachHotStartEntries { index ->
+ assertTrue("$index", provider.isHotStartForStartedActivity(homeActivityClass))
+ }
+ }
+
+ @Test
+ fun `GIVEN the app started for an activity WHEN we launched HA through a drawing IntentRA THEN start up is not cold`() {
+ // These entries mimic observed behavior for local code changes.
+ logEntries.addAll(
+ listOf(
+ LogEntry.ActivityCreated(irActivityClass),
+ LogEntry.ActivityStarted(irActivityClass),
+ LogEntry.AppStarted,
+ LogEntry.ActivityCreated(homeActivityClass),
+ LogEntry.ActivityStarted(homeActivityClass),
+ LogEntry.ActivityStopped(irActivityClass),
+ ),
+ )
+ assertFalse(provider.isColdStartForStartedActivity(homeActivityClass))
+ }
+
+ @Test
+ fun `GIVEN the app started for an activity WHEN we launched HA through a drawing IntentRA THEN start up is not warm`() {
+ // These entries mimic observed behavior for local code changes.
+ logEntries.addAll(
+ listOf(
+ LogEntry.AppStopped,
+ LogEntry.ActivityStopped(homeActivityClass),
+ LogEntry.ActivityCreated(irActivityClass),
+ LogEntry.ActivityStarted(irActivityClass),
+ LogEntry.AppStarted,
+ LogEntry.ActivityCreated(homeActivityClass),
+ LogEntry.ActivityStarted(homeActivityClass),
+ LogEntry.ActivityStopped(irActivityClass),
+ ),
+ )
+ assertFalse(provider.isWarmStartForStartedActivity(homeActivityClass))
+ }
+
+ @Test
+ fun `GIVEN the app started for an activity WHEN we launched HA through a drawing IntentRA THEN start up is not hot`() {
+ // These entries mimic observed behavior for local code changes.
+ logEntries.addAll(
+ listOf(
+ LogEntry.AppStopped,
+ LogEntry.ActivityStopped(homeActivityClass),
+ LogEntry.ActivityCreated(irActivityClass),
+ LogEntry.ActivityStarted(irActivityClass),
+ LogEntry.AppStarted,
+ LogEntry.ActivityStarted(homeActivityClass),
+ LogEntry.ActivityStopped(irActivityClass),
+ ),
+ )
+ assertFalse(provider.isHotStartForStartedActivity(homeActivityClass))
+ }
+
+ @Test
+ fun `GIVEN the app started for an activity WHEN two HomeActivities are created THEN start up is not cold`() {
+ // We're making an assumption about how this would work based on previous observed patterns.
+ // AIUI, we should never have more than one HomeActivity.
+ logEntries.addAll(
+ listOf(
+ LogEntry.ActivityCreated(homeActivityClass),
+ LogEntry.ActivityStarted(homeActivityClass),
+ LogEntry.AppStarted,
+ LogEntry.ActivityCreated(homeActivityClass),
+ LogEntry.ActivityStarted(homeActivityClass),
+ LogEntry.ActivityStopped(homeActivityClass),
+ ),
+ )
+ assertFalse(provider.isColdStartForStartedActivity(homeActivityClass))
+ }
+
+ @Test
+ fun `GIVEN the app started for an activity WHEN an activity hasn't been created yet THEN start up is not cold`() {
+ assertFalse(provider.isColdStartForStartedActivity(homeActivityClass))
+ }
+
+ @Test
+ fun `GIVEN the app started for an activity WHEN an activity hasn't started yet THEN start up is not cold`() {
+ logEntries.addAll(
+ listOf(
+ LogEntry.ActivityCreated(homeActivityClass),
+ ),
+ )
+ assertFalse(provider.isColdStartForStartedActivity(homeActivityClass))
+ }
+
+ @Test
+ fun `GIVEN the app did not start for an activity WHEN is cold is checked THEN it returns false`() {
+ every { startReasonProvider.reason } returns StartReason.NON_ACTIVITY
+
+ assertFalse(provider.isColdStartForStartedActivity(homeActivityClass))
+
+ forEachColdStartEntries { index ->
+ assertFalse("$index", provider.isColdStartForStartedActivity(homeActivityClass))
+ }
+ }
+
+ @Test
+ fun `GIVEN the app has been stopped WHEN is cold short circuit is called THEN it returns true`() {
+ logEntries.add(LogEntry.AppStopped)
+ assertTrue(provider.shouldShortCircuitColdStart())
+ }
+
+ @Test
+ fun `GIVEN the app has not been stopped WHEN is cold short circuit is called THEN it returns false`() {
+ assertFalse(provider.shouldShortCircuitColdStart())
+ }
+
+ @Test
+ fun `GIVEN the app has not been stopped WHEN an activity has not been created THEN it's not a warm start`() {
+ assertFalse(provider.isWarmStartForStartedActivity(homeActivityClass))
+ }
+
+ @Test
+ fun `GIVEN the app has been stopped WHEN an activity has not been created THEN it's not a warm start`() {
+ logEntries.add(LogEntry.AppStopped)
+ assertFalse(provider.isWarmStartForStartedActivity(homeActivityClass))
+ }
+
+ @Test
+ fun `GIVEN the app has been stopped WHEN an activity has not been started THEN it's not a warm start`() {
+ logEntries.addAll(
+ listOf(
+ LogEntry.AppStopped,
+ LogEntry.ActivityCreated(homeActivityClass),
+ ),
+ )
+ assertFalse(provider.isWarmStartForStartedActivity(homeActivityClass))
+ }
+
+ @Test
+ fun `GIVEN the app has not been stopped WHEN an activity has not been created THEN it's not a hot start`() {
+ assertFalse(provider.isHotStartForStartedActivity(homeActivityClass))
+ }
+
+ @Test
+ fun `GIVEN the app has been stopped WHEN an activity has not been created THEN it's not a hot start`() {
+ logEntries.add(LogEntry.AppStopped)
+ assertFalse(provider.isHotStartForStartedActivity(homeActivityClass))
+ }
+
+ @Test
+ fun `GIVEN the app has been stopped WHEN an activity has not been started THEN it's not a hot start`() {
+ logEntries.addAll(
+ listOf(
+ LogEntry.AppStopped,
+ LogEntry.ActivityCreated(homeActivityClass),
+ ),
+ )
+ assertFalse(provider.isHotStartForStartedActivity(homeActivityClass))
+ }
+
+ @Test
+ fun `GIVEN the app started for an activity WHEN it is a cold start THEN get startup state is cold`() {
+ forEachColdStartEntries { index ->
+ assertEquals("$index", StartupState.COLD, provider.getStartupStateForStartedActivity(homeActivityClass))
+ }
+ }
+
+ @Test
+ fun `WHEN it is a warm start THEN get startup state is warm`() {
+ forEachWarmStartEntries { index ->
+ assertEquals("$index", StartupState.WARM, provider.getStartupStateForStartedActivity(homeActivityClass))
+ }
+ }
+
+ @Test
+ fun `WHEN it is a hot start THEN get startup state is hot`() {
+ forEachHotStartEntries { index ->
+ assertEquals("$index", StartupState.HOT, provider.getStartupStateForStartedActivity(homeActivityClass))
+ }
+ }
+
+ @Test
+ fun `WHEN two activities are started THEN get startup state is unknown`() {
+ logEntries.addAll(
+ listOf(
+ LogEntry.ActivityCreated(irActivityClass),
+ LogEntry.ActivityStarted(irActivityClass),
+ LogEntry.AppStarted,
+ LogEntry.ActivityCreated(homeActivityClass),
+ LogEntry.ActivityStarted(homeActivityClass),
+ LogEntry.ActivityStopped(irActivityClass),
+ ),
+ )
+
+ assertEquals(StartupState.UNKNOWN, provider.getStartupStateForStartedActivity(homeActivityClass))
+ }
+
+ private fun forEachColdStartEntries(block: (index: Int) -> Unit) {
+ // These entries mimic observed behavior.
+ //
+ // MAIN: open HomeActivity directly.
+ val coldStartEntries = listOf(
+ listOf(
+ LogEntry.ActivityCreated(homeActivityClass),
+ LogEntry.ActivityStarted(homeActivityClass),
+ LogEntry.AppStarted,
+
+ // VIEW: open non-drawing IntentReceiverActivity, then HomeActivity.
+ ),
+ listOf(
+ LogEntry.ActivityCreated(irActivityClass),
+ LogEntry.ActivityCreated(homeActivityClass),
+ LogEntry.ActivityStarted(homeActivityClass),
+ LogEntry.AppStarted,
+ ),
+ )
+
+ forEachStartEntry(coldStartEntries, block)
+ }
+
+ private fun forEachWarmStartEntries(block: (index: Int) -> Unit) {
+ // These entries mimic observed behavior. We test both truncated (i.e. the current behavior
+ // with the optimization to prevent an infinite log) and untruncated (the behavior without
+ // such an optimization).
+ //
+ // truncated MAIN: open HomeActivity directly.
+ val warmStartEntries = listOf(
+ listOf(
+ LogEntry.AppStopped,
+ LogEntry.ActivityStopped(homeActivityClass),
+ LogEntry.ActivityCreated(homeActivityClass),
+ LogEntry.ActivityStarted(homeActivityClass),
+ LogEntry.AppStarted,
+
+ // untruncated MAIN: open HomeActivity directly.
+ ),
+ listOf(
+ LogEntry.ActivityCreated(homeActivityClass),
+ LogEntry.ActivityStarted(homeActivityClass),
+ LogEntry.AppStarted,
+ LogEntry.AppStopped,
+ LogEntry.ActivityStopped(homeActivityClass),
+ LogEntry.ActivityCreated(homeActivityClass),
+ LogEntry.ActivityStarted(homeActivityClass),
+ LogEntry.AppStarted,
+
+ // truncated VIEW: open non-drawing IntentReceiverActivity, then HomeActivity.
+ ),
+ listOf(
+ LogEntry.AppStopped,
+ LogEntry.ActivityStopped(homeActivityClass),
+ LogEntry.ActivityCreated(irActivityClass),
+ LogEntry.ActivityCreated(homeActivityClass),
+ LogEntry.ActivityStarted(homeActivityClass),
+ LogEntry.AppStarted,
+
+ // untruncated VIEW: open non-drawing IntentReceiverActivity, then HomeActivity.
+ ),
+ listOf(
+ LogEntry.ActivityCreated(irActivityClass),
+ LogEntry.ActivityCreated(homeActivityClass),
+ LogEntry.ActivityStarted(homeActivityClass),
+ LogEntry.AppStarted,
+ LogEntry.AppStopped,
+ LogEntry.ActivityStopped(homeActivityClass),
+ LogEntry.ActivityCreated(irActivityClass),
+ LogEntry.ActivityCreated(homeActivityClass),
+ LogEntry.ActivityStarted(homeActivityClass),
+ LogEntry.AppStarted,
+ ),
+ )
+
+ forEachStartEntry(warmStartEntries, block)
+ }
+
+ private fun forEachHotStartEntries(block: (index: Int) -> Unit) {
+ // These entries mimic observed behavior. We test both truncated (i.e. the current behavior
+ // with the optimization to prevent an infinite log) and untruncated (the behavior without
+ // such an optimization).
+ //
+ // truncated MAIN: open HomeActivity directly.
+ val hotStartEntries = listOf(
+ listOf(
+ LogEntry.AppStopped,
+ LogEntry.ActivityStopped(homeActivityClass),
+ LogEntry.ActivityStarted(homeActivityClass),
+ LogEntry.AppStarted,
+
+ // untruncated MAIN: open HomeActivity directly.
+ ),
+ listOf(
+ LogEntry.ActivityCreated(homeActivityClass),
+ LogEntry.ActivityStarted(homeActivityClass),
+ LogEntry.AppStarted,
+ LogEntry.AppStopped,
+ LogEntry.ActivityStopped(homeActivityClass),
+ LogEntry.ActivityStarted(homeActivityClass),
+ LogEntry.AppStarted,
+
+ // truncated VIEW: open non-drawing IntentReceiverActivity, then HomeActivity.
+ ),
+ listOf(
+ LogEntry.AppStopped,
+ LogEntry.ActivityStopped(homeActivityClass),
+ LogEntry.ActivityCreated(irActivityClass),
+ LogEntry.ActivityStarted(homeActivityClass),
+ LogEntry.AppStarted,
+
+ // untruncated VIEW: open non-drawing IntentReceiverActivity, then HomeActivity.
+ ),
+ listOf(
+ LogEntry.ActivityCreated(irActivityClass),
+ LogEntry.ActivityCreated(homeActivityClass),
+ LogEntry.ActivityStarted(homeActivityClass),
+ LogEntry.AppStarted,
+ LogEntry.AppStopped,
+ LogEntry.ActivityStopped(homeActivityClass),
+ LogEntry.ActivityCreated(irActivityClass),
+ LogEntry.ActivityStarted(homeActivityClass),
+ LogEntry.AppStarted,
+ ),
+ )
+
+ forEachStartEntry(hotStartEntries, block)
+ }
+
+ private fun forEachStartEntry(entries: List<List<LogEntry>>, block: (index: Int) -> Unit) {
+ entries.forEachIndexed { index, startEntry ->
+ logEntries.clear()
+ logEntries.addAll(startEntry)
+ block(index)
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StartupTimelineStateMachineTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StartupTimelineStateMachineTest.kt
new file mode 100644
index 0000000000..91e658871d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StartupTimelineStateMachineTest.kt
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.perf
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.mozilla.fenix.perf.StartupTimelineStateMachine.StartupActivity
+import org.mozilla.fenix.perf.StartupTimelineStateMachine.StartupDestination
+import org.mozilla.fenix.perf.StartupTimelineStateMachine.StartupDestination.APP_LINK
+import org.mozilla.fenix.perf.StartupTimelineStateMachine.StartupDestination.HOMESCREEN
+import org.mozilla.fenix.perf.StartupTimelineStateMachine.StartupDestination.UNKNOWN
+import org.mozilla.fenix.perf.StartupTimelineStateMachine.StartupState.Cold
+import org.mozilla.fenix.perf.StartupTimelineStateMachine.getNextState
+
+class StartupTimelineStateMachineTest {
+
+ @Test
+ fun `GIVEN state cold-unknown WHEN home activity is first shown THEN we are in cold-homescreen state`() {
+ val actual = getNextState(Cold(UNKNOWN), StartupActivity.HOME)
+ assertEquals(Cold(HOMESCREEN), actual)
+ }
+
+ @Test
+ fun `GIVEN state cold-unknown WHEN intent receiver activity is first shown THEN we are in cold-app-link state`() {
+ val actual = getNextState(Cold(UNKNOWN), StartupActivity.INTENT_RECEIVER)
+ assertEquals(Cold(APP_LINK), actual)
+ }
+
+ @Test
+ fun `GIVEN state cold + known destination WHEN any activity is passed in THEN we remain in the same state`() {
+ val knownDestinations = StartupDestination.values().filter { it != UNKNOWN }
+ val allActivities = StartupActivity.values()
+
+ knownDestinations.forEach { destination ->
+ val initial = Cold(destination)
+ allActivities.forEach { activity ->
+ val actual = getNextState(initial, activity)
+ assertEquals("$destination $activity", initial, actual)
+ }
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StartupTypeTelemetryTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StartupTypeTelemetryTest.kt
new file mode 100644
index 0000000000..cea5787011
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StartupTypeTelemetryTest.kt
@@ -0,0 +1,150 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.perf
+
+import androidx.lifecycle.Lifecycle
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import kotlinx.coroutines.test.advanceUntilIdle
+import mozilla.components.support.ktx.kotlin.crossProduct
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import mozilla.telemetry.glean.testing.GleanTestRule
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.PerfStartup
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.perf.StartupPathProvider.StartupPath
+import org.mozilla.fenix.perf.StartupStateProvider.StartupState
+
+private val validTelemetryLabels = run {
+ val allStates = listOf("cold", "warm", "hot", "unknown")
+ val allPaths = listOf("main", "view", "unknown")
+
+ allStates.crossProduct(allPaths) { state, path -> "${state}_$path" }.toSet()
+}
+
+private val activityClass = HomeActivity::class.java
+
+@RunWith(AndroidJUnit4::class)
+class StartupTypeTelemetryTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ private lateinit var telemetry: StartupTypeTelemetry
+ private lateinit var callbacks: StartupTypeTelemetry.StartupTypeLifecycleObserver
+
+ @MockK private lateinit var stateProvider: StartupStateProvider
+
+ @MockK private lateinit var pathProvider: StartupPathProvider
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+ telemetry = spyk(StartupTypeTelemetry(stateProvider, pathProvider))
+ callbacks = telemetry.getTestCallbacks()
+ }
+
+ @Test
+ fun `WHEN attach is called THEN it is registered to the lifecycle`() {
+ val lifecycle = mockk<Lifecycle>(relaxed = true)
+ telemetry.attachOnHomeActivityOnCreate(lifecycle)
+
+ verify { lifecycle.addObserver(any()) }
+ }
+
+ @Test
+ fun `GIVEN all possible path and state combinations WHEN record telemetry THEN the labels are incremented the appropriate number of times`() = runTestOnMain {
+ val allPossibleInputArgs = StartupState.values().toList().crossProduct(
+ StartupPath.values().toList(),
+ ) { state, path ->
+ Pair(state, path)
+ }
+
+ allPossibleInputArgs.forEach { (state, path) ->
+ every { stateProvider.getStartupStateForStartedActivity(activityClass) } returns state
+ every { pathProvider.startupPathForActivity } returns path
+
+ telemetry.record(coroutinesTestRule.testDispatcher)
+ advanceUntilIdle()
+ }
+
+ validTelemetryLabels.forEach { label ->
+ // Path == NOT_SET gets bucketed with Path == UNKNOWN so we'll increment twice for those.
+ val expected = if (label.endsWith("unknown")) 2 else 1
+ assertEquals("label: $label", expected, PerfStartup.startupType[label].testGetValue())
+ }
+
+ // All invalid labels go to a single bucket: let's verify it has no value.
+ assertNull(PerfStartup.startupType["__other__"].testGetValue())
+ }
+
+ @Test
+ fun `WHEN record is called THEN telemetry is recorded with the appropriate label`() = runTestOnMain {
+ every { stateProvider.getStartupStateForStartedActivity(activityClass) } returns StartupState.COLD
+ every { pathProvider.startupPathForActivity } returns StartupPath.MAIN
+
+ telemetry.record(coroutinesTestRule.testDispatcher)
+ advanceUntilIdle()
+
+ assertEquals(1, PerfStartup.startupType["cold_main"].testGetValue())
+ }
+
+ @Test
+ fun `GIVEN the activity is launched WHEN onResume is called THEN we record the telemetry`() {
+ launchApp()
+ verify(exactly = 1) { telemetry.record(any()) }
+ }
+
+ @Test
+ fun `GIVEN the activity is launched WHEN the activity is paused and resumed THEN record is not called`() {
+ // This part of the test duplicates another test but it's needed to initialize the state of this test.
+ launchApp()
+ verify(exactly = 1) { telemetry.record(any()) }
+
+ callbacks.onPause(mockk())
+ callbacks.onResume(mockk())
+
+ verify(exactly = 1) { telemetry.record(any()) } // i.e. this shouldn't be called again.
+ }
+
+ @Test
+ fun `GIVEN the activity is launched WHEN the activity is stopped and resumed THEN record is called again`() {
+ // This part of the test duplicates another test but it's needed to initialize the state of this test.
+ launchApp()
+ verify(exactly = 1) { telemetry.record(any()) }
+
+ callbacks.onPause(mockk())
+ callbacks.onStop(mockk())
+ callbacks.onStart(mockk())
+ callbacks.onResume(mockk())
+
+ verify(exactly = 2) { telemetry.record(any()) } // i.e. this should be called again.
+ }
+
+ private fun launchApp() {
+ // What these return isn't important.
+ every { stateProvider.getStartupStateForStartedActivity(activityClass) } returns StartupState.COLD
+ every { pathProvider.startupPathForActivity } returns StartupPath.MAIN
+
+ callbacks.onCreate(mockk())
+ callbacks.onStart(mockk())
+ callbacks.onResume(mockk())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StorageStatsMetricsTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StorageStatsMetricsTest.kt
new file mode 100644
index 0000000000..079b6469b9
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StorageStatsMetricsTest.kt
@@ -0,0 +1,62 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.perf
+
+import android.app.usage.StorageStats
+import android.app.usage.StorageStatsManager
+import android.content.Context
+import androidx.core.content.getSystemService
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.RelaxedMockK
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.GleanMetrics.StorageStats as Metrics
+
+@RunWith(FenixRobolectricTestRunner::class) // gleanTestRule
+class StorageStatsMetricsTest {
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @RelaxedMockK private lateinit var mockContext: Context
+
+ @RelaxedMockK private lateinit var storageStats: StorageStats
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+
+ every {
+ mockContext.getSystemService<StorageStatsManager>()?.queryStatsForUid(any(), any())
+ } returns storageStats
+ }
+
+ @Test
+ fun `WHEN reporting THEN the values from the storageStats are accumulated`() {
+ every { storageStats.appBytes } returns 100
+ every { storageStats.cacheBytes } returns 200
+ every { storageStats.dataBytes } returns 1000
+
+ StorageStatsMetrics.reportSync(mockContext)
+
+ assertEquals(100, Metrics.appBytes.testGetValue()!!.sum)
+ assertEquals(200, Metrics.cacheBytes.testGetValue()!!.sum)
+ assertEquals(800, Metrics.dataDirBytes.testGetValue()!!.sum)
+ }
+
+ @Test
+ fun `WHEN reporting THEN the query duration is measured`() {
+ StorageStatsMetrics.reportSync(mockContext)
+ assertNotNull(Metrics.queryStatsDuration.testGetValue())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StrictModeManagerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StrictModeManagerTest.kt
new file mode 100644
index 0000000000..e1d9a0f9ed
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/StrictModeManagerTest.kt
@@ -0,0 +1,133 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.perf
+
+import android.os.StrictMode
+import androidx.fragment.app.FragmentManager
+import io.mockk.MockKAnnotations
+import io.mockk.confirmVerified
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.slot
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.fail
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.Config
+import org.mozilla.fenix.ReleaseChannel
+import org.mozilla.fenix.components.Components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class StrictModeManagerTest {
+
+ private lateinit var debugManager: StrictModeManager
+ private lateinit var releaseManager: StrictModeManager
+
+ @MockK(relaxUnitFun = true)
+ private lateinit var fragmentManager: FragmentManager
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ mockkStatic(StrictMode::class)
+
+ val components: Components = mockk(relaxed = true)
+
+ // These tests log a warning that mockk couldn't set the backing field of Config.channel
+ // but it doesn't seem to impact their correctness so I'm ignoring it.
+ val debugConfig: Config = mockk { every { channel } returns ReleaseChannel.Debug }
+ debugManager = StrictModeManager(debugConfig, components)
+
+ val releaseConfig: Config = mockk { every { channel } returns ReleaseChannel.Release }
+ releaseManager = StrictModeManager(releaseConfig, components)
+ }
+
+ @After
+ fun teardown() {
+ unmockkStatic(StrictMode::class)
+ }
+
+ @Test
+ fun `GIVEN we're in a release build WHEN we enable strict mode THEN we don't set policies`() {
+ releaseManager.enableStrictMode(false)
+ verify(exactly = 0) { StrictMode.setThreadPolicy(any()) }
+ verify(exactly = 0) { StrictMode.setVmPolicy(any()) }
+ }
+
+ @Test
+ fun `GIVEN we're in a debug build WHEN we enable strict mode THEN we set policies`() {
+ debugManager.enableStrictMode(false)
+ verify { StrictMode.setThreadPolicy(any()) }
+ verify { StrictMode.setVmPolicy(any()) }
+ }
+
+ @Test
+ fun `GIVEN we're in a debug build WHEN we attach a listener THEN we attach to the fragment lifecycle and detach when onFragmentResumed is called`() {
+ val callbacks = slot<FragmentManager.FragmentLifecycleCallbacks>()
+
+ debugManager.attachListenerToDisablePenaltyDeath(fragmentManager)
+ verify { fragmentManager.registerFragmentLifecycleCallbacks(capture(callbacks), false) }
+ confirmVerified(fragmentManager)
+
+ callbacks.captured.onFragmentResumed(fragmentManager, mockk())
+ verify { fragmentManager.unregisterFragmentLifecycleCallbacks(callbacks.captured) }
+ }
+
+ @Test
+ fun `GIVEN we're in a release build WHEN resetAfter is called THEN we return the value from the function block`() {
+ val expected = "Hello world"
+ val actual = releaseManager.resetAfter(StrictMode.allowThreadDiskReads()) { expected }
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `GIVEN we're in a debug build WHEN resetAfter is called THEN we return the value from the function block`() {
+ val expected = "Hello world"
+ val actual = debugManager.resetAfter(StrictMode.allowThreadDiskReads()) { expected }
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `GIVEN we're in a release build WHEN resetAfter is called THEN the old policy is not set`() {
+ releaseManager.resetAfter(StrictMode.allowThreadDiskReads()) { "" }
+ verify(exactly = 0) { StrictMode.setThreadPolicy(any()) }
+ }
+
+ @Test
+ fun `GIVEN we're in a debug build WHEN resetAfter is called THEN the old policy is set`() {
+ val expectedPolicy = StrictMode.allowThreadDiskReads()
+ debugManager.resetAfter(expectedPolicy) { "" }
+ verify { StrictMode.setThreadPolicy(expectedPolicy) }
+ }
+
+ @Test
+ fun `GIVEN we're in a debug build WHEN resetAfter is called and an exception is thrown from the function THEN the old policy is set`() {
+ val expectedPolicy = StrictMode.allowThreadDiskReads()
+ try {
+ debugManager.resetAfter(expectedPolicy) {
+ throw IllegalStateException()
+ }
+
+ @Suppress("UNREACHABLE_CODE")
+ fail("Expected previous method to throw.")
+ } catch (e: IllegalStateException) { /* Do nothing */ }
+
+ verify { StrictMode.setThreadPolicy(expectedPolicy) }
+ }
+
+ @Test
+ fun `GIVEN we're in debug mode WHEN we suppress StrictMode THEN the suppressed count increases`() {
+ assertEquals(0, debugManager.suppressionCount.get())
+ debugManager.resetAfter(StrictMode.allowThreadDiskReads()) { "" }
+ assertEquals(1, debugManager.suppressionCount.get())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/ThreadPenaltyDeathWithIgnoresListenerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/ThreadPenaltyDeathWithIgnoresListenerTest.kt
new file mode 100644
index 0000000000..a2e050cafd
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/perf/ThreadPenaltyDeathWithIgnoresListenerTest.kt
@@ -0,0 +1,113 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.perf
+
+import android.os.strictmode.Violation
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.impl.annotations.RelaxedMockK
+import io.mockk.mockkObject
+import io.mockk.unmockkAll
+import io.mockk.verify
+import mozilla.components.support.base.log.logger.Logger
+import mozilla.components.support.utils.ManufacturerCodes
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.helpers.StackTraces
+
+class ThreadPenaltyDeathWithIgnoresListenerTest {
+
+ @RelaxedMockK private lateinit var logger: Logger
+ private lateinit var listener: ThreadPenaltyDeathWithIgnoresListener
+
+ @MockK private lateinit var violation: Violation
+ private lateinit var stackTrace: Array<StackTraceElement>
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+
+ listener = ThreadPenaltyDeathWithIgnoresListener(logger)
+
+ stackTrace = emptyArray()
+ every { violation.stackTrace } answers { stackTrace }
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ }
+
+ @Test(expected = RuntimeException::class)
+ fun `WHEN provided an arbitrary violation that does not conflict with ignores THEN we throw an exception`() {
+ stackTrace = Exception().stackTrace
+ listener.onThreadViolation(violation)
+ }
+
+ @Test
+ fun `GIVEN we're on a Samsung WHEN provided an IdsController violation THEN it will be ignored and logged`() {
+ mockkObject(ManufacturerCodes)
+ every { ManufacturerCodes.isSamsung } returns true
+
+ every { violation.stackTrace } returns getIdsControllerStackTrace()
+ listener.onThreadViolation(violation)
+
+ verify { logger.debug("Ignoring StrictMode ThreadPolicy violation", violation) }
+ }
+
+ @Test(expected = RuntimeException::class)
+ fun `GIVEN we're not on a Samsung WHEN provided an IdsController violation THEN we throw an exception`() {
+ mockkObject(ManufacturerCodes)
+ every { ManufacturerCodes.isSamsung } returns false
+
+ every { violation.stackTrace } returns getIdsControllerStackTrace()
+ listener.onThreadViolation(violation)
+ }
+
+ @Test
+ fun `GIVEN we're on a Samsung WHEN provided the EdmStorageProvider violation THEN it will be ignored and logged`() {
+ mockkObject(ManufacturerCodes)
+ every { ManufacturerCodes.isSamsung } returns true
+
+ every { violation.stackTrace } returns getEdmStorageProviderStackTrace()
+ listener.onThreadViolation(violation)
+
+ verify { logger.debug("Ignoring StrictMode ThreadPolicy violation", violation) }
+ }
+
+ @Test(expected = RuntimeException::class)
+ fun `GIVEN we're not on a Samsung or LG WHEN provided the EdmStorageProvider violation THEN we throw an exception`() {
+ mockkObject(ManufacturerCodes)
+ every { ManufacturerCodes.isSamsung } returns false
+ every { ManufacturerCodes.isLG } returns false
+
+ every { violation.stackTrace } returns getEdmStorageProviderStackTrace()
+ listener.onThreadViolation(violation)
+ }
+
+ @Test
+ fun `WHEN provided the InstrumentationHooks violation THEN it will be ignored and logged`() {
+ every { violation.stackTrace } returns getInstrumentationHooksStackTrace()
+ listener.onThreadViolation(violation)
+
+ verify { logger.debug("Ignoring StrictMode ThreadPolicy violation", violation) }
+ }
+
+ @Test
+ fun `WHEN violation is null THEN we don't throw an exception`() {
+ listener.onThreadViolation(null)
+ }
+
+ private fun getIdsControllerStackTrace() =
+ StackTraces.getStackTraceFromLogcat("IdsControllerLogcat.txt")
+
+ private fun getEdmStorageProviderStackTrace() =
+ StackTraces.getStackTraceFromLogcat("EdmStorageProviderBaseLogcat.txt")
+
+ private fun getInstrumentationHooksStackTrace() =
+ StackTraces.getStackTraceFromLogcat("InstrumentationHooksLogcat.txt")
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/push/WebPushEngineIntegrationTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/push/WebPushEngineIntegrationTest.kt
new file mode 100644
index 0000000000..e642085b6c
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/push/WebPushEngineIntegrationTest.kt
@@ -0,0 +1,227 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.push
+
+import android.util.Base64
+import io.mockk.Called
+import io.mockk.CapturingSlot
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.slot
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import mozilla.components.concept.engine.Engine
+import mozilla.components.concept.engine.webpush.WebPushDelegate
+import mozilla.components.concept.engine.webpush.WebPushHandler
+import mozilla.components.concept.engine.webpush.WebPushSubscription
+import mozilla.components.feature.push.AutoPushFeature
+import mozilla.components.feature.push.AutoPushSubscription
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.helpers.MockkRetryTestRule
+
+class WebPushEngineIntegrationTest {
+
+ private val scope = TestScope(UnconfinedTestDispatcher())
+
+ @MockK private lateinit var engine: Engine
+
+ @MockK private lateinit var pushFeature: AutoPushFeature
+
+ @MockK(relaxed = true)
+ private lateinit var handler: WebPushHandler
+ private lateinit var delegate: CapturingSlot<WebPushDelegate>
+ private lateinit var integration: WebPushEngineIntegration
+
+ @get:Rule
+ val mockkRule = MockkRetryTestRule()
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ mockkStatic(Base64::class)
+ delegate = slot()
+
+ every { engine.registerWebPushDelegate(capture(delegate)) } returns handler
+ every { pushFeature.register(any()) } just Runs
+ every { pushFeature.unregister(any()) } just Runs
+ every { Base64.decode(any<ByteArray>(), any()) } answers { firstArg() }
+
+ integration = WebPushEngineIntegration(engine, pushFeature, scope)
+ }
+
+ @After
+ fun teardown() {
+ unmockkStatic(Base64::class)
+ }
+
+ @Test
+ fun `methods are no-op before calling start`() = scope.runTest {
+ integration.onMessageReceived("push", null)
+ integration.onSubscriptionChanged("push")
+ verify { handler wasNot Called }
+
+ integration.start()
+
+ integration.onMessageReceived("push", null)
+ verify { handler.onPushMessage("push", null) }
+
+ integration.onSubscriptionChanged("push")
+ verify { handler.onSubscriptionChanged("push") }
+ }
+
+ @Test
+ fun `start and stop register and unregister pushFeature`() {
+ integration.start()
+ verify { pushFeature.register(integration) }
+
+ integration.stop()
+ verify { pushFeature.unregister(integration) }
+ }
+
+ @Test
+ fun `delegate calls getSubscription`() {
+ integration.start()
+ var subscribeFn: ((AutoPushSubscription?) -> Unit)? = null
+ every { pushFeature.getSubscription("scope", block = any()) } answers {
+ subscribeFn = thirdArg()
+ }
+
+ var actualSubscription: WebPushSubscription? = null
+ delegate.captured.onGetSubscription(
+ "scope",
+ onSubscription = {
+ actualSubscription = it
+ },
+ )
+
+ assertNull(actualSubscription)
+ assertNotNull(subscribeFn)
+
+ subscribeFn!!(
+ AutoPushSubscription(
+ scope = "scope",
+ publicKey = "abc",
+ endpoint = "def",
+ authKey = "xyz",
+ appServerKey = null,
+ ),
+ )
+
+ val expectedSubscription = WebPushSubscription(
+ scope = "scope",
+ publicKey = "abc".toByteArray(),
+ endpoint = "def",
+ authSecret = "xyz".toByteArray(),
+ appServerKey = null,
+ )
+ assertEquals(expectedSubscription, actualSubscription)
+ }
+
+ @Test
+ fun `delegate calls subscribe`() {
+ integration.start()
+ var onSubscribeErrorFn: ((Exception) -> Unit)? = null
+ var onSubscribeFn: ((AutoPushSubscription?) -> Unit)? = null
+ every {
+ pushFeature.subscribe(
+ scope = "scope",
+ appServerKey = null,
+ onSubscribeError = any(),
+ onSubscribe = any(),
+ )
+ } answers {
+ onSubscribeErrorFn = thirdArg()
+ onSubscribeFn = lastArg()
+ }
+
+ var actualSubscription: WebPushSubscription? = null
+ var onSubscribeInvoked = false
+ delegate.captured.onSubscribe("scope", null) {
+ actualSubscription = it
+ onSubscribeInvoked = true
+ }
+ assertFalse(onSubscribeInvoked)
+ assertNull(actualSubscription)
+
+ assertNotNull(onSubscribeErrorFn)
+ onSubscribeErrorFn!!(mockk())
+ assertTrue(onSubscribeInvoked)
+ assertNull(actualSubscription)
+
+ assertNotNull(onSubscribeFn)
+ onSubscribeFn!!(
+ AutoPushSubscription(
+ scope = "scope",
+ publicKey = "abc",
+ endpoint = "def",
+ authKey = "xyz",
+ appServerKey = null,
+ ),
+ )
+
+ val expectedSubscription = WebPushSubscription(
+ scope = "scope",
+ publicKey = "abc".toByteArray(),
+ endpoint = "def",
+ authSecret = "xyz".toByteArray(),
+ appServerKey = null,
+ )
+
+ assertEquals(expectedSubscription, actualSubscription)
+ }
+
+ @Test
+ fun `delegate calls unsubscribe`() {
+ integration.start()
+ var onUnsubscribeErrorFn: ((Exception) -> Unit)? = null
+ var onUnsubscribeFn: ((Boolean) -> Unit)? = null
+ every {
+ pushFeature.unsubscribe(
+ scope = "scope",
+ onUnsubscribeError = any(),
+ onUnsubscribe = any(),
+ )
+ } answers {
+ onUnsubscribeErrorFn = secondArg()
+ onUnsubscribeFn = thirdArg()
+ }
+
+ var onSubscribeInvoked = false
+ var unsubscribeSuccess: Boolean? = null
+ delegate.captured.onUnsubscribe("scope") {
+ onSubscribeInvoked = true
+ unsubscribeSuccess = it
+ }
+
+ assertFalse(onSubscribeInvoked)
+ assertNull(unsubscribeSuccess)
+
+ assertNotNull(onUnsubscribeErrorFn)
+ onUnsubscribeErrorFn!!(mockk())
+ assertNotNull(unsubscribeSuccess)
+ assertFalse(unsubscribeSuccess!!)
+
+ assertNotNull(onUnsubscribeFn)
+ onUnsubscribeFn!!(true)
+ assertNotNull(unsubscribeSuccess)
+ assertTrue(unsubscribeSuccess!!)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/SearchDialogControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/SearchDialogControllerTest.kt
new file mode 100644
index 0000000000..d1e10dcfea
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/SearchDialogControllerTest.kt
@@ -0,0 +1,648 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.search
+
+import androidx.appcompat.app.AlertDialog
+import androidx.navigation.NavController
+import androidx.navigation.NavDirections
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.spyk
+import io.mockk.unmockkObject
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.state.action.AwesomeBarAction
+import mozilla.components.browser.state.action.BrowserAction
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.search.SearchEngine
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.feature.tabs.TabsUseCases
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.middleware.CaptureActionsMiddleware
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.BrowserDirection
+import org.mozilla.fenix.GleanMetrics.Events
+import org.mozilla.fenix.GleanMetrics.UnifiedSearch
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.R
+import org.mozilla.fenix.browser.browsingmode.BrowsingMode
+import org.mozilla.fenix.components.Core
+import org.mozilla.fenix.components.metrics.MetricsUtils
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.search.SearchDialogFragmentDirections.Companion.actionGlobalAddonsManagementFragment
+import org.mozilla.fenix.search.SearchDialogFragmentDirections.Companion.actionGlobalSearchEngineFragment
+import org.mozilla.fenix.search.toolbar.SearchSelectorMenu
+import org.mozilla.fenix.settings.SupportUtils
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class) // for gleanTestRule
+class SearchDialogControllerTest {
+
+ @MockK(relaxed = true)
+ private lateinit var activity: HomeActivity
+
+ @MockK(relaxed = true)
+ private lateinit var store: SearchDialogFragmentStore
+
+ @MockK(relaxed = true)
+ private lateinit var navController: NavController
+
+ @MockK private lateinit var searchEngine: SearchEngine
+
+ @MockK(relaxed = true)
+ private lateinit var settings: Settings
+
+ private lateinit var middleware: CaptureActionsMiddleware<BrowserState, BrowserAction>
+ private lateinit var browserStore: BrowserStore
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+ mockkObject(MetricsUtils)
+ middleware = CaptureActionsMiddleware()
+ browserStore = BrowserStore(
+ middleware = listOf(middleware),
+ )
+ every { store.state.tabId } returns "test-tab-id"
+ every { store.state.searchEngineSource.searchEngine } returns searchEngine
+ every { searchEngine.type } returns SearchEngine.Type.BUNDLED
+ every { navController.currentDestination } returns mockk {
+ every { id } returns R.id.searchDialogFragment
+ }
+ every { MetricsUtils.recordSearchMetrics(searchEngine, any(), any()) } just Runs
+ }
+
+ @After
+ fun teardown() {
+ unmockkObject(MetricsUtils)
+ }
+
+ @Test
+ fun `GIVEN default search engine is selected WHEN url is committed THEN load the url`() {
+ val url = "https://www.google.com/"
+ assertNull(Events.enteredUrl.testGetValue())
+
+ every { store.state.defaultEngine } returns searchEngine
+
+ createController().handleUrlCommitted(url)
+
+ browserStore.waitUntilIdle()
+
+ verify {
+ activity.openToBrowserAndLoad(
+ searchTermOrURL = url,
+ newTab = false,
+ from = BrowserDirection.FromSearchDialog,
+ engine = searchEngine,
+ forceSearch = false,
+ )
+ }
+
+ middleware.assertLastAction(AwesomeBarAction.EngagementFinished::class) { action ->
+ assertFalse(action.abandoned)
+ }
+
+ assertNotNull(Events.enteredUrl.testGetValue())
+ val snapshot = Events.enteredUrl.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("false", snapshot.single().extra?.getValue("autocomplete"))
+ }
+
+ @Test
+ fun `GIVEN a general search engine is selected WHEN url is committed THEN perform search`() {
+ val url = "https://www.google.com/"
+ assertNull(Events.enteredUrl.testGetValue())
+
+ every { store.state.defaultEngine } returns mockk(relaxed = true)
+
+ createController().handleUrlCommitted(url)
+
+ browserStore.waitUntilIdle()
+
+ verify {
+ activity.openToBrowserAndLoad(
+ searchTermOrURL = url,
+ newTab = false,
+ from = BrowserDirection.FromSearchDialog,
+ engine = searchEngine,
+ forceSearch = true,
+ )
+ }
+
+ middleware.assertLastAction(AwesomeBarAction.EngagementFinished::class) { action ->
+ assertFalse(action.abandoned)
+ }
+
+ assertNotNull(Events.enteredUrl.testGetValue())
+ val snapshot = Events.enteredUrl.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("false", snapshot.single().extra?.getValue("autocomplete"))
+ }
+
+ @Test
+ fun handleBlankUrlCommitted() {
+ val url = ""
+
+ var dismissDialogInvoked = false
+ createController(
+ dismissDialog = {
+ dismissDialogInvoked = true
+ },
+ ).handleUrlCommitted(url)
+
+ browserStore.waitUntilIdle()
+
+ assertTrue(dismissDialogInvoked)
+
+ middleware.assertLastAction(AwesomeBarAction.EngagementFinished::class) { action ->
+ assertTrue(action.abandoned)
+ }
+ }
+
+ @Test
+ fun handleSearchCommitted() {
+ val searchTerm = "Firefox"
+
+ createController().handleUrlCommitted(searchTerm)
+
+ browserStore.waitUntilIdle()
+
+ verify {
+ activity.openToBrowserAndLoad(
+ searchTermOrURL = searchTerm,
+ newTab = false,
+ from = BrowserDirection.FromSearchDialog,
+ engine = searchEngine,
+ forceSearch = true,
+ )
+ }
+
+ middleware.assertLastAction(AwesomeBarAction.EngagementFinished::class) { action ->
+ assertFalse(action.abandoned)
+ }
+ }
+
+ @Test
+ fun `WHEN the search engine is added by the application THEN do not load URL`() {
+ every { searchEngine.type } returns SearchEngine.Type.APPLICATION
+
+ val searchTerm = "Firefox"
+ var dismissDialogInvoked = false
+
+ createController(
+ dismissDialog = {
+ dismissDialogInvoked = true
+ },
+ ).handleUrlCommitted(searchTerm)
+
+ browserStore.waitUntilIdle()
+
+ verify(exactly = 0) {
+ activity.openToBrowserAndLoad(
+ searchTermOrURL = any(),
+ newTab = any(),
+ from = any(),
+ engine = any(),
+ )
+ }
+
+ assertFalse(dismissDialogInvoked)
+
+ middleware.assertNotDispatched(AwesomeBarAction.EngagementFinished::class)
+ }
+
+ @Test
+ fun handleCrashesUrlCommitted() {
+ val url = "about:crashes"
+ every { activity.packageName } returns "org.mozilla.fenix"
+
+ createController().handleUrlCommitted(url)
+
+ browserStore.waitUntilIdle()
+
+ verify {
+ activity.startActivity(any())
+ }
+
+ middleware.assertLastAction(AwesomeBarAction.EngagementFinished::class) { action ->
+ assertFalse(action.abandoned)
+ }
+ }
+
+ @Test
+ fun handleAddonsUrlCommitted() {
+ val url = "about:addons"
+ val directions = actionGlobalAddonsManagementFragment()
+
+ createController().handleUrlCommitted(url)
+
+ browserStore.waitUntilIdle()
+
+ verify { navController.navigate(directions) }
+
+ middleware.assertLastAction(AwesomeBarAction.EngagementFinished::class) { action ->
+ assertFalse(action.abandoned)
+ }
+ }
+
+ @Test
+ fun handleMozillaUrlCommitted() {
+ val url = "moz://a"
+ assertNull(Events.enteredUrl.testGetValue())
+
+ every { store.state.defaultEngine } returns searchEngine
+
+ createController().handleUrlCommitted(url)
+
+ browserStore.waitUntilIdle()
+
+ verify {
+ activity.openToBrowserAndLoad(
+ searchTermOrURL = SupportUtils.getMozillaPageUrl(SupportUtils.MozillaPage.MANIFESTO),
+ newTab = false,
+ from = BrowserDirection.FromSearchDialog,
+ engine = searchEngine,
+ )
+ }
+
+ middleware.assertLastAction(AwesomeBarAction.EngagementFinished::class) { action ->
+ assertFalse(action.abandoned)
+ }
+
+ assertNotNull(Events.enteredUrl.testGetValue())
+ val snapshot = Events.enteredUrl.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("false", snapshot.single().extra?.getValue("autocomplete"))
+ }
+
+ @Test
+ fun handleEditingCancelled() = runTest {
+ var clearToolbarFocusInvoked = false
+ var dismissAndGoBack = false
+ createController(
+ clearToolbarFocus = {
+ clearToolbarFocusInvoked = true
+ },
+ dismissDialogAndGoBack = {
+ dismissAndGoBack = true
+ },
+ ).handleEditingCancelled()
+
+ assertTrue(clearToolbarFocusInvoked)
+ assertTrue(dismissAndGoBack)
+
+ middleware.assertLastAction(AwesomeBarAction.EngagementFinished::class) { action ->
+ assertTrue(action.abandoned)
+ }
+ }
+
+ @Test
+ fun handleTextChangedNonEmpty() {
+ val text = "fenix"
+
+ createController().handleTextChanged(text)
+
+ browserStore.waitUntilIdle()
+
+ verify { store.dispatch(SearchFragmentAction.UpdateQuery(text)) }
+
+ middleware.assertNotDispatched(AwesomeBarAction.EngagementFinished::class)
+ }
+
+ @Test
+ fun handleTextChangedEmpty() {
+ val text = ""
+
+ createController().handleTextChanged(text)
+
+ browserStore.waitUntilIdle()
+
+ verify { store.dispatch(SearchFragmentAction.UpdateQuery(text)) }
+
+ middleware.assertNotDispatched(AwesomeBarAction.EngagementFinished::class)
+ }
+
+ @Test
+ fun `WHEN felt privacy is enabled THEN do not dispatch AllowSearchSuggestionsInPrivateModePrompt`() {
+ every { settings.feltPrivateBrowsingEnabled } returns true
+
+ val text = "mozilla"
+
+ createController().handleTextChanged(text)
+
+ browserStore.waitUntilIdle()
+
+ val actionSlot = mutableListOf<SearchFragmentAction>()
+ verify { store.dispatch(capture(actionSlot)) }
+ assertFalse(actionSlot.any { it is SearchFragmentAction.AllowSearchSuggestionsInPrivateModePrompt })
+
+ middleware.assertNotDispatched(AwesomeBarAction.EngagementFinished::class)
+ }
+
+ @Test
+ fun `WHEN felt privacy is disabled THEN dispatch AllowSearchSuggestionsInPrivateModePrompt`() {
+ every { settings.feltPrivateBrowsingEnabled } returns false
+
+ val text = "mozilla"
+
+ createController().handleTextChanged(text)
+
+ browserStore.waitUntilIdle()
+
+ val actionSlot = mutableListOf<SearchFragmentAction>()
+ verify { store.dispatch(capture(actionSlot)) }
+ assertTrue(actionSlot.any { it is SearchFragmentAction.AllowSearchSuggestionsInPrivateModePrompt })
+
+ middleware.assertNotDispatched(AwesomeBarAction.EngagementFinished::class)
+ }
+
+ @Test
+ fun handleUrlTapped() {
+ val url = "https://www.google.com/"
+ val flags = EngineSession.LoadUrlFlags.all()
+ assertNull(Events.enteredUrl.testGetValue())
+
+ createController().handleUrlTapped(url, flags)
+ createController().handleUrlTapped(url)
+
+ browserStore.waitUntilIdle()
+
+ verify {
+ activity.openToBrowserAndLoad(
+ searchTermOrURL = url,
+ newTab = false,
+ from = BrowserDirection.FromSearchDialog,
+ flags = flags,
+ )
+ }
+
+ assertNotNull(Events.enteredUrl.testGetValue())
+ val snapshot = Events.enteredUrl.testGetValue()!!
+ assertEquals(2, snapshot.size)
+ assertEquals("false", snapshot.first().extra?.getValue("autocomplete"))
+ assertEquals("false", snapshot[1].extra?.getValue("autocomplete"))
+
+ middleware.assertLastAction(AwesomeBarAction.EngagementFinished::class) { action ->
+ assertFalse(action.abandoned)
+ }
+ }
+
+ @Test
+ fun handleSearchTermsTapped() {
+ val searchTerms = "fenix"
+
+ createController().handleSearchTermsTapped(searchTerms)
+
+ browserStore.waitUntilIdle()
+
+ verify {
+ activity.openToBrowserAndLoad(
+ searchTermOrURL = searchTerms,
+ newTab = false,
+ from = BrowserDirection.FromSearchDialog,
+ engine = searchEngine,
+ forceSearch = true,
+ )
+ }
+
+ middleware.assertLastAction(AwesomeBarAction.EngagementFinished::class) { action ->
+ assertFalse(action.abandoned)
+ }
+ }
+
+ @Test
+ fun handleSearchShortcutEngineSelected() {
+ val searchEngine: SearchEngine = mockk(relaxed = true)
+ val browsingMode = BrowsingMode.Private
+ every { activity.browsingModeManager.mode } returns browsingMode
+
+ var focusToolbarInvoked = false
+ createController(
+ focusToolbar = {
+ focusToolbarInvoked = true
+ },
+ ).handleSearchShortcutEngineSelected(searchEngine)
+
+ browserStore.waitUntilIdle()
+
+ assertTrue(focusToolbarInvoked)
+ verify { store.dispatch(SearchFragmentAction.SearchShortcutEngineSelected(searchEngine, browsingMode, settings)) }
+
+ middleware.assertNotDispatched(AwesomeBarAction.EngagementFinished::class)
+
+ assertNotNull(UnifiedSearch.engineSelected.testGetValue())
+ val recordedEvents = UnifiedSearch.engineSelected.testGetValue()!!
+ assertEquals(1, recordedEvents.size)
+ val eventExtra = recordedEvents.single().extra
+ assertNotNull(eventExtra)
+ assertTrue(eventExtra!!.containsKey("engine"))
+ assertEquals(searchEngine.name, eventExtra["engine"])
+ }
+
+ @Test
+ fun `WHEN history search engine is selected THEN dispatch correct action`() {
+ val searchEngine: SearchEngine = mockk(relaxed = true)
+ every { searchEngine.type } returns SearchEngine.Type.APPLICATION
+ every { searchEngine.id } returns Core.HISTORY_SEARCH_ENGINE_ID
+
+ assertNull(UnifiedSearch.engineSelected.testGetValue())
+
+ var focusToolbarInvoked = false
+ createController(
+ focusToolbar = {
+ focusToolbarInvoked = true
+ },
+ ).handleSearchShortcutEngineSelected(searchEngine)
+
+ browserStore.waitUntilIdle()
+
+ assertTrue(focusToolbarInvoked)
+ verify { store.dispatch(SearchFragmentAction.SearchHistoryEngineSelected(searchEngine)) }
+
+ middleware.assertNotDispatched(AwesomeBarAction.EngagementFinished::class)
+
+ assertNotNull(UnifiedSearch.engineSelected.testGetValue())
+ val recordedEvents = UnifiedSearch.engineSelected.testGetValue()!!
+ assertEquals(1, recordedEvents.size)
+ val eventExtra = recordedEvents.single().extra
+ assertNotNull(eventExtra)
+ assertTrue(eventExtra!!.containsKey("engine"))
+ assertEquals("history", eventExtra["engine"])
+ }
+
+ @Test
+ fun `WHEN bookmarks search engine is selected THEN dispatch correct action`() {
+ val searchEngine: SearchEngine = mockk(relaxed = true)
+ every { searchEngine.type } returns SearchEngine.Type.APPLICATION
+ every { searchEngine.id } returns Core.BOOKMARKS_SEARCH_ENGINE_ID
+
+ assertNull(UnifiedSearch.engineSelected.testGetValue())
+
+ var focusToolbarInvoked = false
+ createController(
+ focusToolbar = {
+ focusToolbarInvoked = true
+ },
+ ).handleSearchShortcutEngineSelected(searchEngine)
+
+ browserStore.waitUntilIdle()
+
+ assertTrue(focusToolbarInvoked)
+ verify { store.dispatch(SearchFragmentAction.SearchBookmarksEngineSelected(searchEngine)) }
+
+ middleware.assertNotDispatched(AwesomeBarAction.EngagementFinished::class)
+
+ assertNotNull(UnifiedSearch.engineSelected.testGetValue())
+ val recordedEvents = UnifiedSearch.engineSelected.testGetValue()!!
+ assertEquals(1, recordedEvents.size)
+ val eventExtra = recordedEvents.single().extra
+ assertNotNull(eventExtra)
+ assertTrue(eventExtra!!.containsKey("engine"))
+ assertEquals("bookmarks", eventExtra["engine"])
+ }
+
+ @Test
+ fun `WHEN tabs search engine is selected THEN dispatch correct action`() {
+ val searchEngine: SearchEngine = mockk(relaxed = true)
+ every { searchEngine.type } returns SearchEngine.Type.APPLICATION
+ every { searchEngine.id } returns Core.TABS_SEARCH_ENGINE_ID
+
+ assertNull(UnifiedSearch.engineSelected.testGetValue())
+
+ var focusToolbarInvoked = false
+ createController(
+ focusToolbar = {
+ focusToolbarInvoked = true
+ },
+ ).handleSearchShortcutEngineSelected(searchEngine)
+
+ browserStore.waitUntilIdle()
+
+ assertTrue(focusToolbarInvoked)
+ verify { store.dispatch(SearchFragmentAction.SearchTabsEngineSelected(searchEngine)) }
+
+ middleware.assertNotDispatched(AwesomeBarAction.EngagementFinished::class)
+
+ assertNotNull(UnifiedSearch.engineSelected.testGetValue())
+ val recordedEvents = UnifiedSearch.engineSelected.testGetValue()!!
+ assertEquals(1, recordedEvents.size)
+ val eventExtra = recordedEvents.single().extra
+ assertNotNull(eventExtra)
+ assertTrue(eventExtra!!.containsKey("engine"))
+ assertEquals("tabs", eventExtra["engine"])
+ }
+
+ @Test
+ fun handleClickSearchEngineSettings() {
+ val directions: NavDirections = actionGlobalSearchEngineFragment()
+
+ createController().handleClickSearchEngineSettings()
+
+ browserStore.waitUntilIdle()
+
+ verify { navController.navigate(directions) }
+
+ middleware.assertLastAction(AwesomeBarAction.EngagementFinished::class) { action ->
+ assertTrue(action.abandoned)
+ }
+ }
+
+ @Test
+ fun handleExistingSessionSelected() {
+ createController().handleExistingSessionSelected("selected")
+
+ browserStore.waitUntilIdle()
+
+ middleware.assertFirstAction(TabListAction.SelectTabAction::class) { action ->
+ assertEquals("selected", action.tabId)
+ }
+
+ verify { activity.openToBrowser(from = BrowserDirection.FromSearchDialog) }
+
+ middleware.assertLastAction(AwesomeBarAction.EngagementFinished::class) { action ->
+ assertFalse(action.abandoned)
+ }
+ }
+
+ @Test
+ fun handleExistingSessionSelected_tabId() {
+ createController().handleExistingSessionSelected("tab-id")
+
+ browserStore.waitUntilIdle()
+
+ middleware.assertFirstAction(TabListAction.SelectTabAction::class) { action ->
+ assertEquals("tab-id", action.tabId)
+ }
+ verify { activity.openToBrowser(from = BrowserDirection.FromSearchDialog) }
+
+ middleware.assertLastAction(AwesomeBarAction.EngagementFinished::class) { action ->
+ assertFalse(action.abandoned)
+ }
+ }
+
+ @Test
+ fun `show camera permissions needed dialog`() {
+ val dialogBuilder: AlertDialog.Builder = mockk(relaxed = true)
+
+ val spyController = spyk(createController())
+ every { spyController.buildDialog() } returns dialogBuilder
+
+ spyController.handleCameraPermissionsNeeded()
+
+ verify { dialogBuilder.show() }
+ }
+
+ @Test
+ fun `GIVEN search settings menu item WHEN search selector menu item is tapped THEN show search engine settings`() {
+ val controller = spyk(createController())
+
+ controller.handleMenuItemTapped(SearchSelectorMenu.Item.SearchSettings)
+
+ verify { controller.handleClickSearchEngineSettings() }
+ }
+
+ private fun createController(
+ clearToolbarFocus: () -> Unit = { },
+ focusToolbar: () -> Unit = { },
+ clearToolbar: () -> Unit = { },
+ dismissDialog: () -> Unit = { },
+ dismissDialogAndGoBack: () -> Unit = { },
+ ): SearchDialogController {
+ return SearchDialogController(
+ activity = activity,
+ store = browserStore,
+ tabsUseCases = TabsUseCases(browserStore),
+ fragmentStore = store,
+ navController = navController,
+ settings = settings,
+ dismissDialog = dismissDialog,
+ clearToolbarFocus = clearToolbarFocus,
+ focusToolbar = focusToolbar,
+ clearToolbar = clearToolbar,
+ dismissDialogAndGoBack = dismissDialogAndGoBack,
+ )
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/SearchDialogFragmentTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/SearchDialogFragmentTest.kt
new file mode 100644
index 0000000000..ff71df0ff1
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/SearchDialogFragmentTest.kt
@@ -0,0 +1,204 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.search
+
+import android.view.WindowManager.LayoutParams
+import androidx.fragment.app.Fragment
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavController
+import androidx.navigation.fragment.findNavController
+import io.mockk.Called
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.spyk
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import mozilla.components.browser.state.search.SearchEngine
+import mozilla.components.browser.state.state.SearchState
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+internal class SearchDialogFragmentTest {
+ private val navController: NavController = mockk()
+ private val fragment = SearchDialogFragment()
+
+ @Before
+ fun setup() {
+ mockkStatic("androidx.navigation.fragment.FragmentKt")
+ every { any<Fragment>().findNavController() } returns navController
+ }
+
+ @After
+ fun teardown() {
+ unmockkStatic("androidx.navigation.fragment.FragmentKt")
+ }
+
+ @Test
+ fun `GIVEN this is the only visible fragment WHEN asking for the previous destination THEN return null`() {
+ every { navController.currentBackStack.value } returns ArrayDeque(listOf(getDestination(fragmentName)))
+
+ assertNull(fragment.getPreviousDestination())
+ }
+
+ @Test
+ fun `GIVEN this and FragmentB on top of this are visible WHEN asking for the previous destination THEN return null`() {
+ every { navController.currentBackStack.value } returns ArrayDeque(
+ listOf(
+ getDestination(fragmentName),
+ getDestination("FragmentB"),
+ ),
+ )
+
+ assertNull(fragment.getPreviousDestination())
+ }
+
+ @Test
+ fun `GIVEN FragmentA, this and FragmentB are visible WHEN asking for the previous destination THEN return FragmentA`() {
+ val fragmentADestination = getDestination("FragmentA")
+ every { navController.currentBackStack.value } returns ArrayDeque(
+ listOf(
+ fragmentADestination,
+ getDestination(fragmentName),
+ getDestination("FragmentB"),
+ ),
+ )
+
+ assertSame(fragmentADestination, fragment.getPreviousDestination())
+ }
+
+ @Test
+ fun `GIVEN FragmentA and this on top of it are visible WHEN asking for the previous destination THEN return FragmentA`() {
+ val fragmentADestination = getDestination("FragmentA")
+ every { navController.currentBackStack.value } returns ArrayDeque(
+ listOf(
+ fragmentADestination,
+ getDestination(fragmentName),
+ ),
+ )
+
+ assertSame(fragmentADestination, fragment.getPreviousDestination())
+ }
+
+ @Test
+ fun `GIVEN the default search engine is currently selected WHEN checking the need to update the current search engine THEN don't to anything`() {
+ val searchDialogFragment = spyk(fragment)
+ val interactor = spyk(SearchDialogInteractor(mockk()))
+
+ every { searchDialogFragment.interactor } returns interactor
+
+ val defaultSearchEngine: SearchEngine = mockk {
+ every { id } returns "default"
+ }
+ val otherSearchEngine: SearchEngine = mockk {
+ every { id } returns "other"
+ }
+
+ every { searchDialogFragment.requireContext() } returns testContext
+ every { testContext.components.core.store.state.search } returns SearchState(
+ regionSearchEngines = listOf(defaultSearchEngine, otherSearchEngine),
+ userSelectedSearchEngineId = "default",
+ )
+
+ searchDialogFragment.maybeSelectShortcutEngine(defaultSearchEngine.id)
+
+ verify { interactor wasNot Called }
+ }
+
+ @Test
+ fun `GIVEN the default search engine is not currently selected WHEN checking the need to update the current search engine THEN update it to the current engine`() {
+ val searchDialogFragment = spyk(fragment)
+ val interactor = spyk(SearchDialogInteractor(mockk()))
+
+ every { searchDialogFragment.interactor } returns interactor
+ every { interactor.onSearchShortcutEngineSelected(any()) } just Runs
+
+ val defaultSearchEngine: SearchEngine = mockk {
+ every { id } returns "default"
+ }
+ val otherSearchEngine: SearchEngine = mockk {
+ every { id } returns "other"
+ }
+
+ every { searchDialogFragment.requireContext() } returns testContext
+ every { testContext.components.core.store.state.search } returns SearchState(
+ regionSearchEngines = listOf(defaultSearchEngine, otherSearchEngine),
+ userSelectedSearchEngineId = "default",
+ )
+
+ searchDialogFragment.maybeSelectShortcutEngine(otherSearchEngine.id)
+
+ verify { interactor.onSearchShortcutEngineSelected(any()) }
+ }
+
+ @Test
+ fun `GIVEN the currently selected search engine is unknown WHEN checking the need to update the current search engine THEN don't do anything`() {
+ fragment.interactor = mockk()
+
+ fragment.maybeSelectShortcutEngine(null)
+
+ verify { fragment.interactor wasNot Called }
+ }
+
+ @Test
+ fun `GIVEN app is in private mode WHEN search dialog is created THEN the dialog is secure`() {
+ val activity: HomeActivity = mockk(relaxed = true)
+ val fragment = spyk(SearchDialogFragment())
+ val layoutParams = LayoutParams()
+ layoutParams.flags = LayoutParams.FLAG_SECURE
+
+ every { activity.browsingModeManager.mode.isPrivate } returns true
+ every { activity.window } returns mockk(relaxed = true) {
+ every { attributes } returns LayoutParams().apply { flags = LayoutParams.FLAG_SECURE }
+ }
+ every { fragment.requireActivity() } returns activity
+ every { fragment.requireContext() } returns testContext
+
+ val dialog = fragment.onCreateDialog(null)
+
+ assertEquals(LayoutParams.FLAG_SECURE, dialog.window?.attributes?.flags!! and LayoutParams.FLAG_SECURE)
+ }
+
+ @Test
+ fun `GIVEN app is in normal mode WHEN search dialog is created THEN the dialog is not secure`() {
+ val activity: HomeActivity = mockk(relaxed = true)
+ val fragment = spyk(SearchDialogFragment())
+ val layoutParams = LayoutParams()
+ layoutParams.flags = LayoutParams.FLAG_SECURE
+
+ every { activity.browsingModeManager.mode.isPrivate } returns false
+ every { activity.window } returns mockk(relaxed = true) {
+ every { attributes } returns LayoutParams().apply { flags = LayoutParams.FLAG_SECURE }
+ }
+ every { fragment.requireActivity() } returns activity
+ every { fragment.requireContext() } returns testContext
+
+ val dialog = fragment.onCreateDialog(null)
+
+ assertEquals(0, dialog.window?.attributes?.flags!! and LayoutParams.FLAG_SECURE)
+ }
+}
+
+private val fragmentName = SearchDialogFragment::class.java.canonicalName?.substringAfterLast('.')!!
+
+private fun getDestination(destinationName: String): NavBackStackEntry {
+ return mockk {
+ every { destination } returns mockk {
+ every { displayName } returns "test.id/$destinationName"
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/SearchDialogInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/SearchDialogInteractorTest.kt
new file mode 100644
index 0000000000..c279e49bac
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/SearchDialogInteractorTest.kt
@@ -0,0 +1,128 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.search
+
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.state.search.SearchEngine
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.search.toolbar.SearchSelectorMenu
+
+class SearchDialogInteractorTest {
+
+ lateinit var searchController: SearchDialogController
+ lateinit var interactor: SearchDialogInteractor
+
+ @Before
+ fun setup() {
+ searchController = mockk(relaxed = true)
+ interactor = SearchDialogInteractor(
+ searchController,
+ )
+ }
+
+ @Test
+ fun onUrlCommitted() {
+ interactor.onUrlCommitted("test")
+
+ verify {
+ searchController.handleUrlCommitted("test")
+ }
+ }
+
+ @Test
+ fun onEditingCanceled() = runTest {
+ interactor.onEditingCanceled()
+
+ verify {
+ searchController.handleEditingCancelled()
+ }
+ }
+
+ @Test
+ fun onTextChanged() {
+ val interactor = SearchDialogInteractor(searchController)
+
+ interactor.onTextChanged("test")
+
+ verify { searchController.handleTextChanged("test") }
+ }
+
+ @Test
+ fun onUrlTapped() {
+ interactor.onUrlTapped("test")
+
+ verify {
+ searchController.handleUrlTapped("test")
+ }
+ }
+
+ @Test
+ fun onSearchTermsTapped() {
+ interactor.onSearchTermsTapped("test")
+ verify {
+ searchController.handleSearchTermsTapped("test")
+ }
+ }
+
+ @Test
+ fun `WHEN the search term from history is tapped THEN delegate the event to the controller`() {
+ interactor.onHistorySearchTermTapped("test")
+ verify {
+ searchController.handleSearchTermsTapped("test")
+ }
+ }
+
+ @Test
+ fun onSearchShortcutEngineSelected() {
+ val searchEngine: SearchEngine = mockk(relaxed = true)
+
+ interactor.onSearchShortcutEngineSelected(searchEngine)
+
+ verify { searchController.handleSearchShortcutEngineSelected(searchEngine) }
+ }
+
+ @Test
+ fun onClickSearchEngineSettings() {
+ interactor.onClickSearchEngineSettings()
+
+ verify {
+ searchController.handleClickSearchEngineSettings()
+ }
+ }
+
+ @Test
+ fun onExistingSessionSelected() {
+ val sessionId = "mozilla"
+
+ interactor.onExistingSessionSelected(sessionId)
+
+ verify {
+ searchController.handleExistingSessionSelected(sessionId)
+ }
+ }
+
+ @Test
+ fun onCameraPermissionsNeeded() {
+ interactor.onCameraPermissionsNeeded()
+
+ verify {
+ searchController.handleCameraPermissionsNeeded()
+ }
+ }
+
+ @Test
+ fun onMenuItemTapped() {
+ val item = SearchSelectorMenu.Item.SearchSettings
+
+ interactor.onMenuItemTapped(item)
+
+ verify {
+ searchController.handleMenuItemTapped(item)
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/SearchFragmentStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/SearchFragmentStoreTest.kt
new file mode 100644
index 0000000000..5a73ec182e
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/SearchFragmentStoreTest.kt
@@ -0,0 +1,1288 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.search
+
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.state.search.RegionState
+import mozilla.components.browser.state.search.SearchEngine
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.ContentState
+import mozilla.components.browser.state.state.SearchState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNotSame
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.browser.browsingmode.BrowsingMode
+import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
+import org.mozilla.fenix.components.Components
+import org.mozilla.fenix.components.metrics.MetricsUtils
+import org.mozilla.fenix.utils.Settings
+
+class SearchFragmentStoreTest {
+
+ @MockK private lateinit var searchEngine: SearchEngine
+
+ @MockK private lateinit var activity: HomeActivity
+
+ @MockK(relaxed = true)
+ private lateinit var components: Components
+
+ @MockK(relaxed = true)
+ private lateinit var settings: Settings
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ every { activity.browsingModeManager } returns object : BrowsingModeManager {
+ override var mode: BrowsingMode = BrowsingMode.Normal
+ }
+ every { components.settings } returns settings
+ }
+
+ @Test
+ fun `createInitialSearchFragmentState with no tab in normal browsing mode`() {
+ activity.browsingModeManager.mode = BrowsingMode.Normal
+ every { components.core.store.state } returns BrowserState()
+ every { settings.shouldShowSearchShortcuts } returns true
+ every { settings.showUnifiedSearchFeature } returns true
+ every { settings.shouldShowHistorySuggestions } returns true
+ every { settings.shouldShowSearchSuggestions } returns true
+ every { settings.shouldShowSearchSuggestionsInPrivate } returns false
+ every { settings.enableFxSuggest } returns true
+ every { settings.showSponsoredSuggestions } returns true
+ every { settings.showNonSponsoredSuggestions } returns true
+
+ mockkStatic("org.mozilla.fenix.search.SearchFragmentStoreKt") {
+ val expected = SearchFragmentState(
+ query = "",
+ url = "",
+ searchTerms = "",
+ searchEngineSource = SearchEngineSource.None,
+ defaultEngine = null,
+ showSearchShortcutsSetting = true,
+ showSearchSuggestions = true,
+ showSearchSuggestionsHint = false,
+ showSearchShortcuts = false,
+ areShortcutsAvailable = false,
+ showClipboardSuggestions = false,
+ showSearchTermHistory = true,
+ showHistorySuggestionsForCurrentEngine = false,
+ showAllHistorySuggestions = true,
+ showBookmarksSuggestionsForCurrentEngine = false,
+ showAllBookmarkSuggestions = false,
+ showSyncedTabsSuggestionsForCurrentEngine = false,
+ showAllSyncedTabsSuggestions = false,
+ showSessionSuggestionsForCurrentEngine = false,
+ showAllSessionSuggestions = true,
+ showSponsoredSuggestions = true,
+ showNonSponsoredSuggestions = true,
+ tabId = null,
+ pastedText = "pastedText",
+ searchAccessPoint = MetricsUtils.Source.ACTION,
+ )
+
+ assertEquals(
+ expected,
+ createInitialSearchFragmentState(
+ activity,
+ components,
+ tabId = null,
+ pastedText = "pastedText",
+ searchAccessPoint = MetricsUtils.Source.ACTION,
+ ),
+ )
+ assertEquals(
+ expected.copy(tabId = "tabId"),
+ createInitialSearchFragmentState(
+ activity,
+ components,
+ tabId = "tabId",
+ pastedText = "pastedText",
+ searchAccessPoint = MetricsUtils.Source.ACTION,
+ ),
+ )
+
+ verify(exactly = 2) { shouldShowSearchSuggestions(BrowsingMode.Normal, settings) }
+ }
+ }
+
+ @Test
+ fun `createInitialSearchFragmentState with no tab in private browsing mode`() {
+ activity.browsingModeManager.mode = BrowsingMode.Private
+ every { components.core.store.state } returns BrowserState()
+ every { settings.shouldShowSearchShortcuts } returns true
+ every { settings.showUnifiedSearchFeature } returns true
+ every { settings.shouldShowHistorySuggestions } returns true
+ every { settings.shouldShowSearchSuggestions } returns true
+ every { settings.shouldShowSearchSuggestionsInPrivate } returns false
+ every { settings.enableFxSuggest } returns true
+ every { settings.showSponsoredSuggestions } returns true
+ every { settings.showNonSponsoredSuggestions } returns true
+
+ val expected = SearchFragmentState(
+ query = "",
+ url = "",
+ searchTerms = "",
+ searchEngineSource = SearchEngineSource.None,
+ defaultEngine = null,
+ showSearchShortcutsSetting = true,
+ showSearchSuggestions = false,
+ showSearchSuggestionsHint = false,
+ showSearchShortcuts = false,
+ areShortcutsAvailable = false,
+ showClipboardSuggestions = false,
+ showSearchTermHistory = true,
+ showHistorySuggestionsForCurrentEngine = false,
+ showAllHistorySuggestions = true,
+ showBookmarksSuggestionsForCurrentEngine = false,
+ showAllBookmarkSuggestions = false,
+ showSyncedTabsSuggestionsForCurrentEngine = false,
+ showAllSyncedTabsSuggestions = false,
+ showSessionSuggestionsForCurrentEngine = false,
+ showAllSessionSuggestions = true,
+ showSponsoredSuggestions = false,
+ showNonSponsoredSuggestions = false,
+ tabId = null,
+ pastedText = "pastedText",
+ searchAccessPoint = MetricsUtils.Source.ACTION,
+ )
+
+ assertEquals(
+ expected,
+ createInitialSearchFragmentState(
+ activity,
+ components,
+ tabId = null,
+ pastedText = "pastedText",
+ searchAccessPoint = MetricsUtils.Source.ACTION,
+ ),
+ )
+ }
+
+ @Test
+ fun `createInitialSearchFragmentState with tab`() {
+ activity.browsingModeManager.mode = BrowsingMode.Private
+ every { components.core.store.state } returns BrowserState(
+ tabs = listOf(
+ TabSessionState(
+ id = "tabId",
+ content = ContentState(
+ url = "https://example.com",
+ searchTerms = "search terms",
+ ),
+ ),
+ ),
+ )
+
+ assertEquals(
+ SearchFragmentState(
+ query = "https://example.com",
+ url = "https://example.com",
+ searchTerms = "search terms",
+ searchEngineSource = SearchEngineSource.None,
+ defaultEngine = null,
+ showSearchSuggestions = false,
+ showSearchShortcutsSetting = false,
+ showSearchSuggestionsHint = false,
+ showSearchShortcuts = false,
+ areShortcutsAvailable = false,
+ showClipboardSuggestions = false,
+ showSearchTermHistory = false,
+ showHistorySuggestionsForCurrentEngine = false,
+ showAllHistorySuggestions = false,
+ showBookmarksSuggestionsForCurrentEngine = false,
+ showAllBookmarkSuggestions = false,
+ showSyncedTabsSuggestionsForCurrentEngine = false,
+ showAllSyncedTabsSuggestions = false,
+ showSessionSuggestionsForCurrentEngine = false,
+ showAllSessionSuggestions = true,
+ showSponsoredSuggestions = false,
+ showNonSponsoredSuggestions = false,
+ tabId = "tabId",
+ pastedText = "",
+ searchAccessPoint = MetricsUtils.Source.SHORTCUT,
+ ),
+ createInitialSearchFragmentState(
+ activity,
+ components,
+ tabId = "tabId",
+ pastedText = "",
+ searchAccessPoint = MetricsUtils.Source.SHORTCUT,
+ ),
+ )
+ }
+
+ @Test
+ fun `GIVEN sponsored and non-sponsored suggestions are enabled and Firefox Suggest is disabled WHEN the initial state is created THEN neither are displayed`() {
+ activity.browsingModeManager.mode = BrowsingMode.Normal
+ every { components.core.store.state } returns BrowserState()
+ every { settings.enableFxSuggest } returns false
+ every { settings.showSponsoredSuggestions } returns true
+ every { settings.showNonSponsoredSuggestions } returns true
+
+ val initialState = createInitialSearchFragmentState(
+ activity,
+ components,
+ tabId = null,
+ pastedText = "pastedText",
+ searchAccessPoint = MetricsUtils.Source.ACTION,
+ )
+ assertFalse(initialState.showSponsoredSuggestions)
+ assertFalse(initialState.showNonSponsoredSuggestions)
+ }
+
+ @Test
+ fun updateQuery() = runTest {
+ val initialState = emptyDefaultState()
+ val store = SearchFragmentStore(initialState)
+ val query = "test query"
+
+ store.dispatch(SearchFragmentAction.UpdateQuery(query)).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(query, store.state.query)
+ }
+
+ @Test
+ fun `GIVEN search shortcuts are disabled and unified search is enabled in settings WHEN the default search engine is selected THEN search shortcuts are not displayed`() = runTest {
+ val initialState = emptyDefaultState(showHistorySuggestionsForCurrentEngine = false)
+ val store = SearchFragmentStore(initialState)
+ every { settings.showUnifiedSearchFeature } returns true
+ every { settings.shouldShowSearchShortcuts } returns false
+
+ store.dispatch(
+ SearchFragmentAction.SearchDefaultEngineSelected(
+ engine = searchEngine,
+ browsingMode = BrowsingMode.Normal,
+ settings = settings,
+ ),
+ ).join()
+
+ assertNotSame(initialState, store.state)
+ assertFalse(store.state.showSearchShortcuts)
+ }
+
+ @Test
+ fun `GIVEN search shortcuts are enabled and unified search is disabled in settings WHEN the default search engine is selected THEN search shortcuts are displayed`() = runTest {
+ val initialState = emptyDefaultState(showHistorySuggestionsForCurrentEngine = false)
+ val store = SearchFragmentStore(initialState)
+ every { settings.showUnifiedSearchFeature } returns false
+ every { settings.shouldShowSearchShortcuts } returns true
+
+ store.dispatch(
+ SearchFragmentAction.SearchDefaultEngineSelected(
+ engine = searchEngine,
+ browsingMode = BrowsingMode.Normal,
+ settings = settings,
+ ),
+ ).join()
+
+ assertNotSame(initialState, store.state)
+ assertTrue(store.state.showSearchShortcuts)
+ }
+
+ @Test
+ fun `GIVEN search shortcuts and unified search are both enabled in settings WHEN the default search engine is selected THEN search shortcuts are not displayed`() = runTest {
+ val initialState = emptyDefaultState(showHistorySuggestionsForCurrentEngine = false)
+ val store = SearchFragmentStore(initialState)
+ every { settings.showUnifiedSearchFeature } returns true
+ every { settings.shouldShowSearchShortcuts } returns true
+
+ store.dispatch(
+ SearchFragmentAction.SearchDefaultEngineSelected(
+ engine = searchEngine,
+ browsingMode = BrowsingMode.Normal,
+ settings = settings,
+ ),
+ ).join()
+
+ assertNotSame(initialState, store.state)
+ assertFalse(store.state.showSearchShortcuts)
+ }
+
+ @Test
+ fun `GIVEN search shortcuts and unified search are both disabled in settings WHEN the default search engine is selected THEN search shortcuts are not displayed`() = runTest {
+ val initialState = emptyDefaultState(showHistorySuggestionsForCurrentEngine = false)
+ val store = SearchFragmentStore(initialState)
+ every { settings.showUnifiedSearchFeature } returns true
+ every { settings.shouldShowSearchShortcuts } returns true
+
+ store.dispatch(
+ SearchFragmentAction.SearchDefaultEngineSelected(
+ engine = searchEngine,
+ browsingMode = BrowsingMode.Normal,
+ settings = settings,
+ ),
+ ).join()
+
+ assertNotSame(initialState, store.state)
+ assertFalse(store.state.showSearchShortcuts)
+ }
+
+ // non default tests
+
+ @Test
+ fun `GIVEN search shortcuts are disabled and unified search is enabled in settings WHEN the search engine shortcut is selected THEN search shortcuts are not displayed`() = runTest {
+ val initialState = emptyDefaultState(showHistorySuggestionsForCurrentEngine = false)
+ val store = SearchFragmentStore(initialState)
+ every { settings.shouldShowSearchShortcuts } returns false
+ every { settings.showUnifiedSearchFeature } returns true
+
+ val newEngine: SearchEngine = mockk {
+ every { id } returns "DuckDuckGo"
+ every { isGeneral } returns true
+ }
+
+ store.dispatch(
+ SearchFragmentAction.SearchShortcutEngineSelected(
+ engine = newEngine,
+ browsingMode = BrowsingMode.Normal,
+ settings = settings,
+ ),
+ ).join()
+
+ assertNotSame(initialState, store.state)
+ assertFalse(store.state.showSearchShortcuts)
+ }
+
+ @Test
+ fun `GIVEN search shortcuts are enabled and unified search is disabled in settings WHEN the search engine shortcut is selected THEN search shortcuts are displayed`() = runTest {
+ val initialState = emptyDefaultState(showHistorySuggestionsForCurrentEngine = false)
+ val store = SearchFragmentStore(initialState)
+ every { settings.shouldShowSearchShortcuts } returns true
+ every { settings.showUnifiedSearchFeature } returns false
+
+ val newEngine: SearchEngine = mockk {
+ every { id } returns "DuckDuckGo"
+ every { isGeneral } returns true
+ }
+
+ store.dispatch(
+ SearchFragmentAction.SearchDefaultEngineSelected(
+ engine = newEngine,
+ browsingMode = BrowsingMode.Normal,
+ settings = settings,
+ ),
+ ).join()
+
+ assertNotSame(initialState, store.state)
+ assertTrue(store.state.showSearchShortcuts)
+ }
+
+ @Test
+ fun `GIVEN search shortcuts and unified search are both enabled in settings WHEN the search engine shortcut is selected THEN search shortcuts are not displayed`() = runTest {
+ val initialState = emptyDefaultState(showHistorySuggestionsForCurrentEngine = false)
+ val store = SearchFragmentStore(initialState)
+ every { settings.shouldShowSearchShortcuts } returns true
+ every { settings.showUnifiedSearchFeature } returns true
+
+ val newEngine: SearchEngine = mockk {
+ every { id } returns "DuckDuckGo"
+ every { isGeneral } returns true
+ }
+
+ store.dispatch(
+ SearchFragmentAction.SearchDefaultEngineSelected(
+ engine = newEngine,
+ browsingMode = BrowsingMode.Normal,
+ settings = settings,
+ ),
+ ).join()
+
+ assertNotSame(initialState, store.state)
+ assertFalse(store.state.showSearchShortcuts)
+ }
+
+ @Test
+ fun `GIVEN search shortcuts and unified search are both disabled in settings WHEN the search engine shortcut is selected THEN search shortcuts are not displayed`() = runTest {
+ val initialState = emptyDefaultState(showHistorySuggestionsForCurrentEngine = false)
+ val store = SearchFragmentStore(initialState)
+ every { settings.shouldShowSearchShortcuts } returns true
+ every { settings.showUnifiedSearchFeature } returns true
+
+ val newEngine: SearchEngine = mockk {
+ every { id } returns "DuckDuckGo"
+ every { isGeneral } returns true
+ }
+
+ store.dispatch(
+ SearchFragmentAction.SearchDefaultEngineSelected(
+ engine = newEngine,
+ browsingMode = BrowsingMode.Normal,
+ settings = settings,
+ ),
+ ).join()
+
+ assertNotSame(initialState, store.state)
+ assertFalse(store.state.showSearchShortcuts)
+ }
+
+ @Test
+ fun `GIVEN sponsored suggestions are enabled WHEN the default search engine is selected THEN sponsored suggestions are displayed`() = runTest {
+ val initialState = emptyDefaultState(showSponsoredSuggestions = false, showNonSponsoredSuggestions = false)
+ val store = SearchFragmentStore(initialState)
+ every { settings.enableFxSuggest } returns true
+ every { settings.showSponsoredSuggestions } returns true
+ every { settings.showNonSponsoredSuggestions } returns false
+
+ store.dispatch(
+ SearchFragmentAction.SearchDefaultEngineSelected(
+ engine = searchEngine,
+ browsingMode = BrowsingMode.Normal,
+ settings = settings,
+ ),
+ ).join()
+
+ assertNotSame(initialState, store.state)
+ assertTrue(store.state.showSponsoredSuggestions)
+ assertFalse(store.state.showNonSponsoredSuggestions)
+ }
+
+ @Test
+ fun `GIVEN non-sponsored suggestions are enabled WHEN the default search engine is selected THEN non-sponsored suggestions are displayed`() = runTest {
+ val initialState = emptyDefaultState(showSponsoredSuggestions = false, showNonSponsoredSuggestions = false)
+ val store = SearchFragmentStore(initialState)
+ every { settings.enableFxSuggest } returns true
+ every { settings.showSponsoredSuggestions } returns false
+ every { settings.showNonSponsoredSuggestions } returns true
+
+ store.dispatch(
+ SearchFragmentAction.SearchDefaultEngineSelected(
+ engine = searchEngine,
+ browsingMode = BrowsingMode.Normal,
+ settings = settings,
+ ),
+ ).join()
+
+ assertNotSame(initialState, store.state)
+ assertFalse(store.state.showSponsoredSuggestions)
+ assertTrue(store.state.showNonSponsoredSuggestions)
+ }
+
+ @Test
+ fun `GIVEN sponsored and non-sponsored suggestions are enabled and Firefox Suggest is enabled WHEN the default search engine is selected THEN both are displayed`() = runTest {
+ val initialState = emptyDefaultState(showSponsoredSuggestions = false, showNonSponsoredSuggestions = false)
+ val store = SearchFragmentStore(initialState)
+ every { settings.enableFxSuggest } returns true
+ every { settings.showSponsoredSuggestions } returns true
+ every { settings.showNonSponsoredSuggestions } returns true
+
+ store.dispatch(
+ SearchFragmentAction.SearchDefaultEngineSelected(
+ engine = searchEngine,
+ browsingMode = BrowsingMode.Normal,
+ settings = settings,
+ ),
+ ).join()
+
+ assertNotSame(initialState, store.state)
+ assertTrue(store.state.showSponsoredSuggestions)
+ assertTrue(store.state.showNonSponsoredSuggestions)
+ }
+
+ @Test
+ fun `GIVEN sponsored and non-sponsored suggestions are enabled and Firefox Suggest is disabled WHEN the default search engine is selected THEN neither are displayed`() = runTest {
+ val initialState = emptyDefaultState(showSponsoredSuggestions = true, showNonSponsoredSuggestions = true)
+ val store = SearchFragmentStore(initialState)
+ every { settings.enableFxSuggest } returns false
+ every { settings.showSponsoredSuggestions } returns true
+ every { settings.showNonSponsoredSuggestions } returns true
+
+ store.dispatch(
+ SearchFragmentAction.SearchDefaultEngineSelected(
+ engine = searchEngine,
+ browsingMode = BrowsingMode.Normal,
+ settings = settings,
+ ),
+ ).join()
+
+ assertNotSame(initialState, store.state)
+ assertFalse(store.state.showSponsoredSuggestions)
+ assertFalse(store.state.showNonSponsoredSuggestions)
+ }
+
+ @Test
+ fun `GIVEN sponsored and non-sponsored suggestions are disabled WHEN the default search engine is selected THEN neither are displayed`() = runTest {
+ val initialState = emptyDefaultState(showSponsoredSuggestions = true, showNonSponsoredSuggestions = true)
+ val store = SearchFragmentStore(initialState)
+ every { settings.enableFxSuggest } returns true
+ every { settings.showSponsoredSuggestions } returns false
+ every { settings.showNonSponsoredSuggestions } returns false
+
+ store.dispatch(
+ SearchFragmentAction.SearchDefaultEngineSelected(
+ engine = searchEngine,
+ browsingMode = BrowsingMode.Normal,
+ settings = settings,
+ ),
+ ).join()
+
+ assertNotSame(initialState, store.state)
+ assertFalse(store.state.showSponsoredSuggestions)
+ assertFalse(store.state.showNonSponsoredSuggestions)
+ }
+
+ @Test
+ fun `GIVEN sponsored and non-sponsored suggestions are enabled WHEN a shortcut is selected THEN neither are displayed`() = runTest {
+ val initialState =
+ emptyDefaultState(showSponsoredSuggestions = true, showNonSponsoredSuggestions = true)
+ val store = SearchFragmentStore(initialState)
+ every { settings.enableFxSuggest } returns true
+ every { settings.showSponsoredSuggestions } returns true
+ every { settings.showNonSponsoredSuggestions } returns true
+
+ store.dispatch(
+ SearchFragmentAction.SearchShortcutEngineSelected(
+ engine = searchEngine,
+ browsingMode = BrowsingMode.Normal,
+ settings = settings,
+ ),
+ ).join()
+
+ assertNotSame(initialState, store.state)
+ assertFalse(store.state.showSponsoredSuggestions)
+ assertFalse(store.state.showNonSponsoredSuggestions)
+ }
+
+ @Test
+ fun `GIVEN sponsored and non-sponsored suggestions are enabled WHEN the history engine is selected THEN neither are displayed`() = runTest {
+ val initialState =
+ emptyDefaultState(showSponsoredSuggestions = true, showNonSponsoredSuggestions = true)
+ val store = SearchFragmentStore(initialState)
+ every { settings.enableFxSuggest } returns true
+ every { settings.showSponsoredSuggestions } returns true
+ every { settings.showNonSponsoredSuggestions } returns true
+
+ store.dispatch(SearchFragmentAction.SearchHistoryEngineSelected(searchEngine)).join()
+
+ assertNotSame(initialState, store.state)
+ assertFalse(store.state.showSponsoredSuggestions)
+ assertFalse(store.state.showNonSponsoredSuggestions)
+ }
+
+ @Test
+ fun `GIVEN sponsored and non-sponsored suggestions are enabled WHEN the bookmarks engine is selected THEN neither are displayed`() = runTest {
+ val initialState =
+ emptyDefaultState(showSponsoredSuggestions = true, showNonSponsoredSuggestions = true)
+ val store = SearchFragmentStore(initialState)
+ every { settings.enableFxSuggest } returns true
+ every { settings.showSponsoredSuggestions } returns true
+ every { settings.showNonSponsoredSuggestions } returns true
+
+ store.dispatch(SearchFragmentAction.SearchBookmarksEngineSelected(searchEngine)).join()
+
+ assertNotSame(initialState, store.state)
+ assertFalse(store.state.showSponsoredSuggestions)
+ assertFalse(store.state.showNonSponsoredSuggestions)
+ }
+
+ @Test
+ fun `GIVEN sponsored and non-sponsored suggestions are enabled WHEN the tabs engine is selected THEN neither are displayed`() = runTest {
+ val initialState =
+ emptyDefaultState(showSponsoredSuggestions = true, showNonSponsoredSuggestions = true)
+ val store = SearchFragmentStore(initialState)
+ every { settings.enableFxSuggest } returns true
+ every { settings.showSponsoredSuggestions } returns true
+ every { settings.showNonSponsoredSuggestions } returns true
+
+ store.dispatch(SearchFragmentAction.SearchTabsEngineSelected(searchEngine)).join()
+
+ assertNotSame(initialState, store.state)
+ assertFalse(store.state.showSponsoredSuggestions)
+ assertFalse(store.state.showNonSponsoredSuggestions)
+ }
+
+ @Test
+ fun `GIVEN private browsing mode WHEN the search engine is the default one THEN search suggestions providers are updated`() = runTest {
+ val initialState = emptyDefaultState(showHistorySuggestionsForCurrentEngine = false)
+ val store = SearchFragmentStore(initialState)
+ every { settings.shouldShowSearchShortcuts } returns false
+ every { settings.shouldShowSearchSuggestions } returns true
+ every { settings.shouldShowClipboardSuggestions } returns true
+ every { settings.shouldShowHistorySuggestions } returns true
+ every { settings.shouldShowBookmarkSuggestions } returns false
+ every { settings.shouldShowSyncedTabsSuggestions } returns false
+ every { settings.shouldShowSearchSuggestions } returns true
+ every { settings.shouldShowSearchSuggestionsInPrivate } returns true
+ every { settings.enableFxSuggest } returns true
+ every { settings.showSponsoredSuggestions } returns true
+ every { settings.showNonSponsoredSuggestions } returns true
+
+ mockkStatic("org.mozilla.fenix.search.SearchFragmentStoreKt") {
+ store.dispatch(
+ SearchFragmentAction.SearchDefaultEngineSelected(
+ engine = searchEngine,
+ browsingMode = BrowsingMode.Private,
+ settings = settings,
+ ),
+ ).join()
+
+ assertNotSame(initialState, store.state)
+ assertEquals(SearchEngineSource.Default(searchEngine), store.state.searchEngineSource)
+
+ assertTrue(store.state.showSearchSuggestions)
+ assertFalse(store.state.showSearchShortcuts)
+ assertTrue(store.state.showClipboardSuggestions)
+ assertFalse(store.state.showSearchTermHistory)
+ assertFalse(store.state.showHistorySuggestionsForCurrentEngine)
+ assertTrue(store.state.showAllHistorySuggestions)
+ assertFalse(store.state.showAllBookmarkSuggestions)
+ assertFalse(store.state.showAllSyncedTabsSuggestions)
+ assertTrue(store.state.showAllSessionSuggestions)
+ assertFalse(store.state.showSponsoredSuggestions)
+ assertFalse(store.state.showNonSponsoredSuggestions)
+ verify { shouldShowSearchSuggestions(BrowsingMode.Private, settings) }
+ }
+ }
+
+ @Test
+ fun `GIVEN normal browsing mode WHEN the search engine is the default one THEN search suggestions providers are updated`() = runTest {
+ val initialState = emptyDefaultState(showHistorySuggestionsForCurrentEngine = false)
+ val store = SearchFragmentStore(initialState)
+ every { settings.shouldShowSearchShortcuts } returns false
+ every { settings.shouldShowSearchSuggestions } returns true
+ every { settings.shouldShowClipboardSuggestions } returns true
+ every { settings.shouldShowHistorySuggestions } returns true
+ every { settings.shouldShowBookmarkSuggestions } returns false
+ every { settings.shouldShowSyncedTabsSuggestions } returns false
+ every { settings.shouldShowSearchSuggestions } returns true
+ every { settings.shouldShowSearchSuggestionsInPrivate } returns true
+ every { settings.enableFxSuggest } returns true
+ every { settings.showSponsoredSuggestions } returns true
+ every { settings.showNonSponsoredSuggestions } returns true
+
+ store.dispatch(
+ SearchFragmentAction.SearchDefaultEngineSelected(
+ engine = searchEngine,
+ browsingMode = BrowsingMode.Normal,
+ settings = settings,
+ ),
+ ).join()
+
+ assertNotSame(initialState, store.state)
+ assertEquals(SearchEngineSource.Default(searchEngine), store.state.searchEngineSource)
+
+ assertTrue(store.state.showSearchSuggestions)
+ assertFalse(store.state.showSearchShortcuts)
+ assertTrue(store.state.showClipboardSuggestions)
+ assertFalse(store.state.showSearchTermHistory)
+ assertFalse(store.state.showHistorySuggestionsForCurrentEngine)
+ assertTrue(store.state.showAllHistorySuggestions)
+ assertFalse(store.state.showAllBookmarkSuggestions)
+ assertFalse(store.state.showAllSyncedTabsSuggestions)
+ assertTrue(store.state.showAllSessionSuggestions)
+ assertTrue(store.state.showSponsoredSuggestions)
+ assertTrue(store.state.showNonSponsoredSuggestions)
+ }
+
+ @Test
+ fun `GIVEN unified search is enabled WHEN the search engine is updated to a general engine shortcut THEN search suggestions providers are updated`() = runTest {
+ val initialState = emptyDefaultState(showHistorySuggestionsForCurrentEngine = false)
+ val store = SearchFragmentStore(initialState)
+ val topicSpecificEngine: SearchEngine = mockk {
+ every { isGeneral } returns false
+ }
+ every { settings.showUnifiedSearchFeature } returns true
+ every { settings.shouldShowSearchShortcuts } returns true
+ every { settings.shouldShowClipboardSuggestions } returns true
+ every { settings.shouldShowHistorySuggestions } returns true
+ every { settings.shouldShowBookmarkSuggestions } returns true
+ every { settings.shouldShowSyncedTabsSuggestions } returns true
+ every { settings.shouldShowSearchSuggestions } returns true
+ every { settings.enableFxSuggest } returns true
+ every { settings.showSponsoredSuggestions } returns true
+ every { settings.showNonSponsoredSuggestions } returns true
+
+ store.dispatch(
+ SearchFragmentAction.SearchShortcutEngineSelected(
+ engine = topicSpecificEngine,
+ browsingMode = BrowsingMode.Normal,
+ settings = settings,
+ ),
+ ).join()
+
+ assertNotSame(initialState, store.state)
+ assertEquals(SearchEngineSource.Shortcut(topicSpecificEngine), store.state.searchEngineSource)
+ assertTrue(store.state.showSearchSuggestions)
+ assertFalse(store.state.showSearchShortcuts)
+ assertTrue(store.state.showClipboardSuggestions)
+ assertTrue(store.state.showSearchTermHistory)
+ assertTrue(store.state.showHistorySuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllHistorySuggestions)
+ assertTrue(store.state.showBookmarksSuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllBookmarkSuggestions)
+ assertTrue(store.state.showSyncedTabsSuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllSyncedTabsSuggestions)
+ assertTrue(store.state.showSessionSuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllSessionSuggestions)
+ assertFalse(store.state.showSponsoredSuggestions)
+ assertFalse(store.state.showNonSponsoredSuggestions)
+
+ every { settings.shouldShowSearchSuggestions } returns false
+ val generalEngine: SearchEngine = mockk {
+ every { isGeneral } returns true
+ }
+ store.dispatch(
+ SearchFragmentAction.SearchShortcutEngineSelected(
+ engine = generalEngine,
+ browsingMode = BrowsingMode.Normal,
+ settings = settings,
+ ),
+ ).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(SearchEngineSource.Shortcut(generalEngine), store.state.searchEngineSource)
+ assertFalse(store.state.showSearchSuggestions)
+ assertFalse(store.state.showSearchShortcuts)
+ assertTrue(store.state.showClipboardSuggestions)
+ assertTrue(store.state.showSearchTermHistory)
+ assertFalse(store.state.showHistorySuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllHistorySuggestions)
+ assertFalse(store.state.showBookmarksSuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllBookmarkSuggestions)
+ assertFalse(store.state.showSyncedTabsSuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllSyncedTabsSuggestions)
+ assertFalse(store.state.showSessionSuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllSessionSuggestions)
+ assertFalse(store.state.showSponsoredSuggestions)
+ assertFalse(store.state.showNonSponsoredSuggestions)
+ }
+
+ @Test
+ fun `GIVEN unified search is enabled WHEN the search engine is updated to a topic specific engine shortcut THEN search suggestions providers are updated`() = runTest {
+ val initialState = emptyDefaultState(showHistorySuggestionsForCurrentEngine = false)
+ val store = SearchFragmentStore(initialState)
+ every { searchEngine.isGeneral } returns false
+ every { settings.showUnifiedSearchFeature } returns true
+ every { settings.shouldShowSearchSuggestions } returns false
+ every { settings.shouldShowSearchShortcuts } returns false
+ every { settings.shouldShowClipboardSuggestions } returns false
+ every { settings.shouldShowHistorySuggestions } returns true
+ every { settings.shouldShowBookmarkSuggestions } returns false
+ every { settings.shouldShowSyncedTabsSuggestions } returns false
+ every { settings.enableFxSuggest } returns true
+ every { settings.showSponsoredSuggestions } returns true
+ every { settings.showNonSponsoredSuggestions } returns true
+
+ store.dispatch(
+ SearchFragmentAction.SearchShortcutEngineSelected(
+ engine = searchEngine,
+ browsingMode = BrowsingMode.Normal,
+ settings = settings,
+ ),
+ ).join()
+
+ assertNotSame(initialState, store.state)
+ assertEquals(SearchEngineSource.Shortcut(searchEngine), store.state.searchEngineSource)
+ assertFalse(store.state.showSearchSuggestions)
+ assertFalse(store.state.showSearchShortcuts)
+ assertFalse(store.state.showClipboardSuggestions)
+ assertTrue(store.state.showSearchTermHistory)
+ assertTrue(store.state.showHistorySuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllHistorySuggestions)
+ assertFalse(store.state.showBookmarksSuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllBookmarkSuggestions)
+ assertFalse(store.state.showSyncedTabsSuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllSyncedTabsSuggestions)
+ assertTrue(store.state.showSessionSuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllSessionSuggestions)
+ assertFalse(store.state.showSponsoredSuggestions)
+ assertFalse(store.state.showNonSponsoredSuggestions)
+ }
+
+ @Test
+ fun `GIVEN unified search is disabled WHEN the search engine is updated to a shortcut THEN search suggestions providers are updated`() = runTest {
+ val initialState = emptyDefaultState(showHistorySuggestionsForCurrentEngine = false)
+ val store = SearchFragmentStore(initialState)
+ every { settings.showUnifiedSearchFeature } returns false
+ every { settings.shouldShowSearchShortcuts } returns true
+ every { settings.shouldShowClipboardSuggestions } returns false
+ every { settings.shouldShowHistorySuggestions } returns true
+ every { settings.shouldShowBookmarkSuggestions } returns false
+ every { settings.shouldShowSyncedTabsSuggestions } returns true
+ every { settings.shouldShowSearchSuggestions } returns true
+ every { settings.shouldShowSearchSuggestionsInPrivate } returns true
+ every { settings.enableFxSuggest } returns true
+ every { settings.showSponsoredSuggestions } returns true
+ every { settings.showNonSponsoredSuggestions } returns true
+
+ store.dispatch(
+ SearchFragmentAction.SearchShortcutEngineSelected(
+ engine = searchEngine,
+ browsingMode = BrowsingMode.Private,
+ settings = settings,
+ ),
+ ).join()
+
+ assertNotSame(initialState, store.state)
+ assertEquals(SearchEngineSource.Shortcut(searchEngine), store.state.searchEngineSource)
+ assertTrue(store.state.showSearchSuggestions)
+ assertTrue(store.state.showSearchShortcuts)
+ assertFalse(store.state.showClipboardSuggestions)
+ assertFalse(store.state.showSearchTermHistory)
+ assertFalse(store.state.showHistorySuggestionsForCurrentEngine)
+ assertTrue(store.state.showAllHistorySuggestions)
+ assertFalse(store.state.showAllBookmarkSuggestions)
+ assertTrue(store.state.showAllSyncedTabsSuggestions)
+ assertTrue(store.state.showAllSessionSuggestions)
+ assertFalse(store.state.showSponsoredSuggestions)
+ assertFalse(store.state.showNonSponsoredSuggestions)
+ }
+
+ @Test
+ fun `GIVEN unified search is enabled WHEN updating the search engine to a topic specific one THEN enable filtered bookmarks, history and tabs suggestions`() = runTest {
+ val initialState = emptyDefaultState()
+ val store = SearchFragmentStore(initialState)
+ val topicSpecificEngine1: SearchEngine = mockk(relaxed = true) {
+ every { name } returns "1"
+ every { isGeneral } returns false
+ }
+ every { settings.showUnifiedSearchFeature } returns true
+
+ every { settings.shouldShowBookmarkSuggestions } returns false
+ every { settings.shouldShowSyncedTabsSuggestions } returns false
+ store.dispatch(
+ SearchFragmentAction.SearchShortcutEngineSelected(
+ engine = topicSpecificEngine1,
+ browsingMode = BrowsingMode.Private,
+ settings = settings,
+ ),
+ ).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(SearchEngineSource.Shortcut(topicSpecificEngine1), store.state.searchEngineSource)
+ assertFalse(store.state.showBookmarksSuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllBookmarkSuggestions)
+ assertFalse(store.state.showSyncedTabsSuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllSyncedTabsSuggestions)
+ assertTrue(store.state.showSessionSuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllSessionSuggestions)
+
+ val topicSpecificEngine2 = topicSpecificEngine1.copy(
+ name = "2",
+ )
+ every { settings.shouldShowBookmarkSuggestions } returns true
+ every { settings.shouldShowSyncedTabsSuggestions } returns true
+ store.dispatch(
+ SearchFragmentAction.SearchShortcutEngineSelected(
+ engine = topicSpecificEngine2,
+ browsingMode = BrowsingMode.Private,
+ settings = settings,
+ ),
+ ).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(SearchEngineSource.Shortcut(topicSpecificEngine2), store.state.searchEngineSource)
+ assertTrue(store.state.showBookmarksSuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllBookmarkSuggestions)
+ assertTrue(store.state.showSyncedTabsSuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllSyncedTabsSuggestions)
+ assertTrue(store.state.showSessionSuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllSessionSuggestions)
+ }
+
+ @Test
+ fun `GIVEN unified search is disabled WHEN updating the search engine to a topic specific one THEN enable bookmarks and tabs suggestions if user enabled`() = runTest {
+ val initialState = emptyDefaultState()
+ val store = SearchFragmentStore(initialState)
+ val topicSpecificEngine1: SearchEngine = mockk(relaxed = true) {
+ every { id } returns "1"
+ every { isGeneral } returns false
+ }
+ every { settings.showUnifiedSearchFeature } returns true
+
+ every { settings.shouldShowBookmarkSuggestions } returns false
+ every { settings.shouldShowSyncedTabsSuggestions } returns true
+ store.dispatch(
+ SearchFragmentAction.SearchShortcutEngineSelected(
+ engine = topicSpecificEngine1,
+ browsingMode = BrowsingMode.Private,
+ settings = settings,
+ ),
+ ).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(SearchEngineSource.Shortcut(topicSpecificEngine1), store.state.searchEngineSource)
+ assertFalse(store.state.showAllBookmarkSuggestions)
+ assertTrue(store.state.showSyncedTabsSuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllSyncedTabsSuggestions)
+ assertTrue(store.state.showSessionSuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllSessionSuggestions)
+
+ val topicSpecificEngine2 = topicSpecificEngine1.copy(
+ id = "2",
+ )
+ every { settings.shouldShowBookmarkSuggestions } returns true
+ every { settings.shouldShowSyncedTabsSuggestions } returns false
+ store.dispatch(
+ SearchFragmentAction.SearchShortcutEngineSelected(
+ engine = topicSpecificEngine2,
+ browsingMode = BrowsingMode.Private,
+ settings = settings,
+ ),
+ ).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(SearchEngineSource.Shortcut(topicSpecificEngine2), store.state.searchEngineSource)
+ assertTrue(store.state.showBookmarksSuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllBookmarkSuggestions)
+ assertFalse(store.state.showSyncedTabsSuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllSyncedTabsSuggestions)
+ assertTrue(store.state.showSessionSuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllSessionSuggestions)
+ }
+
+ @Test
+ fun `WHEN doing a history search THEN search suggestions providers are updated`() = runTest {
+ val initialState = emptyDefaultState(showHistorySuggestionsForCurrentEngine = true)
+ val store = SearchFragmentStore(initialState)
+
+ store.dispatch(SearchFragmentAction.SearchHistoryEngineSelected(searchEngine)).join()
+
+ assertNotSame(initialState, store.state)
+ assertEquals(SearchEngineSource.History(searchEngine), store.state.searchEngineSource)
+ assertFalse(store.state.showSearchSuggestions)
+ assertFalse(store.state.showSearchShortcuts)
+ assertFalse(store.state.showClipboardSuggestions)
+ assertFalse(store.state.showSearchTermHistory)
+ assertFalse(store.state.showHistorySuggestionsForCurrentEngine)
+ assertTrue(store.state.showAllHistorySuggestions)
+ assertFalse(store.state.showAllBookmarkSuggestions)
+ assertFalse(store.state.showAllSyncedTabsSuggestions)
+ assertFalse(store.state.showAllSessionSuggestions)
+ assertFalse(store.state.showSponsoredSuggestions)
+ assertFalse(store.state.showNonSponsoredSuggestions)
+ }
+
+ @Test
+ fun `WHEN doing a bookmarks search THEN search suggestions providers are updated`() = runTest {
+ val initialState = emptyDefaultState(showHistorySuggestionsForCurrentEngine = true)
+ val store = SearchFragmentStore(initialState)
+
+ store.dispatch(SearchFragmentAction.SearchBookmarksEngineSelected(searchEngine)).join()
+
+ assertNotSame(initialState, store.state)
+ assertEquals(SearchEngineSource.Bookmarks(searchEngine), store.state.searchEngineSource)
+ assertFalse(store.state.showSearchSuggestions)
+ assertFalse(store.state.showSearchShortcuts)
+ assertFalse(store.state.showClipboardSuggestions)
+ assertFalse(store.state.showSearchTermHistory)
+ assertFalse(store.state.showHistorySuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllHistorySuggestions)
+ assertTrue(store.state.showAllBookmarkSuggestions)
+ assertFalse(store.state.showAllSyncedTabsSuggestions)
+ assertFalse(store.state.showAllSessionSuggestions)
+ assertFalse(store.state.showSponsoredSuggestions)
+ assertFalse(store.state.showNonSponsoredSuggestions)
+ }
+
+ @Test
+ fun `WHEN doing a tabs search THEN search suggestions providers are updated`() = runTest {
+ val initialState = emptyDefaultState(showHistorySuggestionsForCurrentEngine = true)
+ val store = SearchFragmentStore(initialState)
+
+ store.dispatch(SearchFragmentAction.SearchTabsEngineSelected(searchEngine)).join()
+
+ assertNotSame(initialState, store.state)
+ assertEquals(SearchEngineSource.Tabs(searchEngine), store.state.searchEngineSource)
+ assertFalse(store.state.showSearchSuggestions)
+ assertFalse(store.state.showSearchShortcuts)
+ assertFalse(store.state.showClipboardSuggestions)
+ assertFalse(store.state.showSearchTermHistory)
+ assertFalse(store.state.showHistorySuggestionsForCurrentEngine)
+ assertFalse(store.state.showAllHistorySuggestions)
+ assertFalse(store.state.showAllBookmarkSuggestions)
+ assertTrue(store.state.showAllSyncedTabsSuggestions)
+ assertTrue(store.state.showAllSessionSuggestions)
+ assertFalse(store.state.showSponsoredSuggestions)
+ assertFalse(store.state.showNonSponsoredSuggestions)
+ }
+
+ @Test
+ fun `WHEN tabs engine selected action dispatched THEN update search engine source`() = runTest {
+ val initialState = emptyDefaultState()
+ val store = SearchFragmentStore(initialState)
+
+ store.dispatch(SearchFragmentAction.SearchTabsEngineSelected(searchEngine)).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(SearchEngineSource.Tabs(searchEngine), store.state.searchEngineSource)
+ }
+
+ @Test
+ fun showSearchSuggestions() = runTest {
+ val initialState = emptyDefaultState()
+ val store = SearchFragmentStore(initialState)
+
+ store.dispatch(SearchFragmentAction.SetShowSearchSuggestions(true)).join()
+ assertNotSame(initialState, store.state)
+ assertTrue(store.state.showSearchSuggestions)
+
+ store.dispatch(SearchFragmentAction.SetShowSearchSuggestions(false)).join()
+ assertFalse(store.state.showSearchSuggestions)
+ }
+
+ @Test
+ fun allowSearchInPrivateMode() = runTest {
+ val initialState = emptyDefaultState()
+ val store = SearchFragmentStore(initialState)
+
+ store.dispatch(SearchFragmentAction.AllowSearchSuggestionsInPrivateModePrompt(true)).join()
+ assertNotSame(initialState, store.state)
+ assertTrue(store.state.showSearchSuggestionsHint)
+
+ store.dispatch(SearchFragmentAction.AllowSearchSuggestionsInPrivateModePrompt(false)).join()
+ assertFalse(store.state.showSearchSuggestionsHint)
+ }
+
+ @Test
+ fun updatingClipboardUrl() {
+ val initialState = emptyDefaultState()
+ val store = SearchFragmentStore(initialState)
+
+ assertFalse(store.state.clipboardHasUrl)
+
+ store.dispatch(
+ SearchFragmentAction.UpdateClipboardHasUrl(true),
+ ).joinBlocking()
+
+ assertTrue(store.state.clipboardHasUrl)
+ }
+
+ @Test
+ fun `Updating SearchFragmentState from SearchState`() {
+ val store = SearchFragmentStore(
+ emptyDefaultState(
+ searchEngineSource = SearchEngineSource.None,
+ areShortcutsAvailable = false,
+ defaultEngine = null,
+ showSearchShortcutsSetting = true,
+ ),
+ )
+
+ assertNull(store.state.defaultEngine)
+ assertFalse(store.state.areShortcutsAvailable)
+ assertFalse(store.state.showSearchShortcuts)
+ assertEquals(SearchEngineSource.None, store.state.searchEngineSource)
+
+ store.dispatch(
+ SearchFragmentAction.UpdateSearchState(
+ search = SearchState(
+ region = RegionState("US", "US"),
+ regionSearchEngines = listOf(
+ SearchEngine("engine-a", "Engine A", mockk(), type = SearchEngine.Type.BUNDLED),
+ SearchEngine("engine-b", "Engine B", mockk(), type = SearchEngine.Type.BUNDLED),
+ SearchEngine("engine-c", "Engine C", mockk(), type = SearchEngine.Type.BUNDLED),
+ ),
+ customSearchEngines = listOf(
+ SearchEngine("engine-d", "Engine D", mockk(), type = SearchEngine.Type.CUSTOM),
+ SearchEngine("engine-e", "Engine E", mockk(), type = SearchEngine.Type.CUSTOM),
+ ),
+ additionalSearchEngines = listOf(
+ SearchEngine("engine-f", "Engine F", mockk(), type = SearchEngine.Type.BUNDLED_ADDITIONAL),
+ ),
+ additionalAvailableSearchEngines = listOf(
+ SearchEngine("engine-g", "Engine G", mockk(), type = SearchEngine.Type.BUNDLED_ADDITIONAL),
+ SearchEngine("engine-h", "Engine H", mockk(), type = SearchEngine.Type.BUNDLED_ADDITIONAL),
+ ),
+ hiddenSearchEngines = listOf(
+ SearchEngine("engine-i", "Engine I", mockk(), type = SearchEngine.Type.BUNDLED),
+ ),
+ regionDefaultSearchEngineId = "engine-b",
+ userSelectedSearchEngineId = null,
+ userSelectedSearchEngineName = null,
+ ),
+ isUnifiedSearchEnabled = false,
+ ),
+ )
+
+ store.waitUntilIdle()
+
+ assertNotNull(store.state.defaultEngine)
+ assertEquals("Engine B", store.state.defaultEngine!!.name)
+
+ assertTrue(store.state.areShortcutsAvailable)
+ assertTrue(store.state.showSearchShortcuts)
+
+ assertTrue(store.state.searchEngineSource is SearchEngineSource.Default)
+ assertNotNull(store.state.searchEngineSource.searchEngine)
+ assertEquals("Engine B", store.state.searchEngineSource.searchEngine!!.name)
+ }
+
+ @Test
+ fun `Updating SearchFragmentState from SearchState - shortcuts disabled`() {
+ val store = SearchFragmentStore(
+ emptyDefaultState(
+ searchEngineSource = SearchEngineSource.None,
+ areShortcutsAvailable = false,
+ defaultEngine = null,
+ showSearchShortcutsSetting = false,
+ ),
+ )
+
+ assertNull(store.state.defaultEngine)
+ assertFalse(store.state.areShortcutsAvailable)
+ assertFalse(store.state.showSearchShortcuts)
+ assertEquals(SearchEngineSource.None, store.state.searchEngineSource)
+
+ store.dispatch(
+ SearchFragmentAction.UpdateSearchState(
+ search = SearchState(
+ region = RegionState("US", "US"),
+ regionSearchEngines = listOf(
+ SearchEngine("engine-a", "Engine A", mockk(), type = SearchEngine.Type.BUNDLED),
+ SearchEngine("engine-b", "Engine B", mockk(), type = SearchEngine.Type.BUNDLED),
+ SearchEngine("engine-c", "Engine C", mockk(), type = SearchEngine.Type.BUNDLED),
+ ),
+ customSearchEngines = listOf(
+ SearchEngine("engine-d", "Engine D", mockk(), type = SearchEngine.Type.CUSTOM),
+ SearchEngine("engine-e", "Engine E", mockk(), type = SearchEngine.Type.CUSTOM),
+ ),
+ additionalSearchEngines = listOf(
+ SearchEngine("engine-f", "Engine F", mockk(), type = SearchEngine.Type.BUNDLED_ADDITIONAL),
+ ),
+ additionalAvailableSearchEngines = listOf(
+ SearchEngine("engine-g", "Engine G", mockk(), type = SearchEngine.Type.BUNDLED_ADDITIONAL),
+ SearchEngine("engine-h", "Engine H", mockk(), type = SearchEngine.Type.BUNDLED_ADDITIONAL),
+ ),
+ hiddenSearchEngines = listOf(
+ SearchEngine("engine-i", "Engine I", mockk(), type = SearchEngine.Type.BUNDLED),
+ ),
+ regionDefaultSearchEngineId = "engine-b",
+ userSelectedSearchEngineId = null,
+ userSelectedSearchEngineName = null,
+ ),
+ isUnifiedSearchEnabled = false,
+ ),
+ )
+
+ store.waitUntilIdle()
+
+ assertNotNull(store.state.defaultEngine)
+ assertEquals("Engine B", store.state.defaultEngine!!.name)
+
+ assertTrue(store.state.areShortcutsAvailable)
+ assertFalse(store.state.showSearchShortcuts)
+
+ assertTrue(store.state.searchEngineSource is SearchEngineSource.Default)
+ assertNotNull(store.state.searchEngineSource.searchEngine)
+ assertEquals("Engine B", store.state.searchEngineSource.searchEngine!!.name)
+ }
+
+ @Test
+ fun `GIVEN unified search is enabled WHEN updating the SearchFragmentState from SearchState THEN disable search shortcuts`() {
+ val store = SearchFragmentStore(
+ emptyDefaultState(
+ searchEngineSource = SearchEngineSource.None,
+ areShortcutsAvailable = false,
+ defaultEngine = null,
+ showSearchShortcutsSetting = false,
+ ),
+ )
+
+ assertFalse(store.state.showSearchShortcuts)
+
+ store.dispatch(
+ SearchFragmentAction.UpdateSearchState(
+ search = SearchState(
+ region = RegionState("US", "US"),
+ regionSearchEngines = listOf(
+ SearchEngine("engine-a", "Engine A", mockk(), type = SearchEngine.Type.BUNDLED),
+ SearchEngine("engine-b", "Engine B", mockk(), type = SearchEngine.Type.BUNDLED),
+ ),
+ customSearchEngines = listOf(),
+ additionalSearchEngines = listOf(),
+ additionalAvailableSearchEngines = listOf(),
+ hiddenSearchEngines = listOf(),
+ regionDefaultSearchEngineId = "engine-b",
+ userSelectedSearchEngineId = null,
+ userSelectedSearchEngineName = null,
+ ),
+ isUnifiedSearchEnabled = true,
+ ),
+ )
+ store.waitUntilIdle()
+
+ assertFalse(store.state.showSearchShortcuts)
+ }
+
+ @Test
+ fun `GIVEN normal browsing mode and search suggestions enabled WHEN checking if search suggestions should be shown THEN return true`() {
+ var settings: Settings = mockk {
+ every { shouldShowSearchSuggestions } returns false
+ every { shouldShowSearchSuggestionsInPrivate } returns false
+ }
+ assertFalse(shouldShowSearchSuggestions(BrowsingMode.Normal, settings))
+
+ settings = mockk {
+ every { shouldShowSearchSuggestions } returns true
+ every { shouldShowSearchSuggestionsInPrivate } returns false
+ }
+ assertTrue(shouldShowSearchSuggestions(BrowsingMode.Normal, settings))
+ }
+
+ @Test
+ fun `GIVEN private browsing mode and search suggestions enabled WHEN checking if search suggestions should be shown THEN return true`() {
+ var settings: Settings = mockk {
+ every { shouldShowSearchSuggestions } returns false
+ every { shouldShowSearchSuggestionsInPrivate } returns false
+ }
+ assertFalse(shouldShowSearchSuggestions(BrowsingMode.Private, settings))
+
+ settings = mockk {
+ every { shouldShowSearchSuggestions } returns false
+ every { shouldShowSearchSuggestionsInPrivate } returns true
+ }
+ assertFalse(shouldShowSearchSuggestions(BrowsingMode.Private, settings))
+
+ settings = mockk {
+ every { shouldShowSearchSuggestions } returns true
+ every { shouldShowSearchSuggestionsInPrivate } returns true
+ }
+ assertTrue(shouldShowSearchSuggestions(BrowsingMode.Private, settings))
+ }
+
+ private fun emptyDefaultState(
+ searchEngineSource: SearchEngineSource = mockk(),
+ defaultEngine: SearchEngine? = mockk(),
+ areShortcutsAvailable: Boolean = true,
+ showSearchShortcutsSetting: Boolean = false,
+ showHistorySuggestionsForCurrentEngine: Boolean = true,
+ showSponsoredSuggestions: Boolean = true,
+ showNonSponsoredSuggestions: Boolean = true,
+ ): SearchFragmentState = SearchFragmentState(
+ tabId = null,
+ url = "",
+ searchTerms = "",
+ query = "",
+ searchEngineSource = searchEngineSource,
+ defaultEngine = defaultEngine,
+ showSearchSuggestionsHint = false,
+ showSearchShortcutsSetting = showSearchShortcutsSetting,
+ showSearchSuggestions = false,
+ showSearchShortcuts = false,
+ areShortcutsAvailable = areShortcutsAvailable,
+ showClipboardSuggestions = false,
+ showSearchTermHistory = true,
+ showHistorySuggestionsForCurrentEngine = showHistorySuggestionsForCurrentEngine,
+ showAllHistorySuggestions = false,
+ showBookmarksSuggestionsForCurrentEngine = false,
+ showAllBookmarkSuggestions = false,
+ showSyncedTabsSuggestionsForCurrentEngine = false,
+ showAllSyncedTabsSuggestions = false,
+ showSessionSuggestionsForCurrentEngine = false,
+ showAllSessionSuggestions = false,
+ showSponsoredSuggestions = showSponsoredSuggestions,
+ showNonSponsoredSuggestions = showNonSponsoredSuggestions,
+ searchAccessPoint = MetricsUtils.Source.NONE,
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/awesomebar/AwesomeBarViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/awesomebar/AwesomeBarViewTest.kt
new file mode 100644
index 0000000000..07448da474
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/awesomebar/AwesomeBarViewTest.kt
@@ -0,0 +1,1484 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.search.awesomebar
+
+import android.app.Activity
+import android.graphics.drawable.VectorDrawable
+import android.net.Uri
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.mockkStatic
+import io.mockk.unmockkObject
+import io.mockk.unmockkStatic
+import mozilla.components.browser.state.search.SearchEngine
+import mozilla.components.browser.state.state.SearchState
+import mozilla.components.browser.state.state.selectedOrDefaultSearchEngine
+import mozilla.components.feature.awesomebar.provider.BookmarksStorageSuggestionProvider
+import mozilla.components.feature.awesomebar.provider.CombinedHistorySuggestionProvider
+import mozilla.components.feature.awesomebar.provider.HistoryStorageSuggestionProvider
+import mozilla.components.feature.awesomebar.provider.SearchActionProvider
+import mozilla.components.feature.awesomebar.provider.SearchEngineSuggestionProvider
+import mozilla.components.feature.awesomebar.provider.SearchSuggestionProvider
+import mozilla.components.feature.awesomebar.provider.SearchTermSuggestionsProvider
+import mozilla.components.feature.awesomebar.provider.SessionSuggestionProvider
+import mozilla.components.feature.fxsuggest.FxSuggestSuggestionProvider
+import mozilla.components.feature.syncedtabs.SyncedTabsStorageSuggestionProvider
+import mozilla.components.support.ktx.android.content.getColorFromAttr
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.browser.browsingmode.BrowsingMode
+import org.mozilla.fenix.components.Core.Companion
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.search.SearchEngineSource
+import org.mozilla.fenix.search.awesomebar.AwesomeBarView.SearchProviderState
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class AwesomeBarViewTest {
+ private var activity: HomeActivity = mockk(relaxed = true)
+ private lateinit var awesomeBarView: AwesomeBarView
+
+ @Before
+ fun setup() {
+ // The following setup is needed to complete the init block of AwesomeBarView
+ mockkStatic("org.mozilla.fenix.ext.ContextKt")
+ mockkStatic("mozilla.components.support.ktx.android.content.ContextKt")
+ mockkObject(AwesomeBarView.Companion)
+ every { any<Activity>().components.core.engine } returns mockk()
+ every { any<Activity>().components.core.icons } returns mockk()
+ every { any<Activity>().components.core.store } returns mockk()
+ every { any<Activity>().components.core.historyStorage } returns mockk()
+ every { any<Activity>().components.core.bookmarksStorage } returns mockk()
+ every { any<Activity>().components.core.client } returns mockk()
+ every { any<Activity>().components.backgroundServices.syncedTabsStorage } returns mockk()
+ every { any<Activity>().components.core.store.state.search } returns mockk(relaxed = true)
+ every { any<Activity>().getColorFromAttr(any()) } returns 0
+ every { AwesomeBarView.Companion.getDrawable(any(), any()) } returns mockk<VectorDrawable>(relaxed = true) {
+ every { intrinsicWidth } returns 10
+ every { intrinsicHeight } returns 10
+ }
+
+ awesomeBarView = AwesomeBarView(
+ activity = activity,
+ interactor = mockk(),
+ view = mockk(),
+ fromHomeFragment = false,
+ )
+ }
+
+ @After
+ fun tearDown() {
+ unmockkStatic("org.mozilla.fenix.ext.ContextKt")
+ unmockkStatic("mozilla.components.support.ktx.android.content.ContextKt")
+ unmockkObject(AwesomeBarView.Companion)
+ }
+
+ @Test
+ fun `GIVEN a search from history and history metadata is enabled and sponsored suggestions are enabled WHEN setting the providers THEN set less suggestions to be shown`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { historyMetadataUIFeature } returns true
+ }
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ searchEngineSource = SearchEngineSource.History(mockk(relaxed = true)),
+ showSponsoredSuggestions = true,
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val historyProvider = result.firstOrNull { it is CombinedHistorySuggestionProvider }
+ assertNotNull(historyProvider)
+ assertEquals(
+ AwesomeBarView.METADATA_SUGGESTION_LIMIT,
+ (historyProvider as CombinedHistorySuggestionProvider).getMaxNumberOfSuggestions(),
+ )
+ }
+
+ @Test
+ fun `GIVEN a search from history and history metadata is enabled and sponsored suggestions are disabled WHEN setting the providers THEN set more suggestions to be shown`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { historyMetadataUIFeature } returns true
+ }
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ searchEngineSource = SearchEngineSource.History(mockk(relaxed = true)),
+ showSponsoredSuggestions = false,
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val historyProvider = result.firstOrNull { it is CombinedHistorySuggestionProvider }
+ assertNotNull(historyProvider)
+ assertEquals(
+ Companion.METADATA_HISTORY_SUGGESTION_LIMIT,
+ (historyProvider as CombinedHistorySuggestionProvider).getMaxNumberOfSuggestions(),
+ )
+ }
+
+ @Test
+ fun `GIVEN a search from history and history metadata is disabled and sponsored suggestions are enabled WHEN setting the providers THEN set less suggestions to be shown`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { historyMetadataUIFeature } returns false
+ }
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ searchEngineSource = SearchEngineSource.History(mockk(relaxed = true)),
+ showSponsoredSuggestions = true,
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val historyProvider = result.firstOrNull { it is HistoryStorageSuggestionProvider }
+ assertNotNull(historyProvider)
+ assertEquals(
+ AwesomeBarView.METADATA_SUGGESTION_LIMIT,
+ (historyProvider as HistoryStorageSuggestionProvider).getMaxNumberOfSuggestions(),
+ )
+ }
+
+ @Test
+ fun `GIVEN a search from history and history metadata is disabled and sponsored suggestions are disabled WHEN setting the providers THEN set more suggestions to be shown`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { historyMetadataUIFeature } returns false
+ }
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ searchEngineSource = SearchEngineSource.History(mockk(relaxed = true)),
+ showSponsoredSuggestions = false,
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val historyProvider = result.firstOrNull { it is HistoryStorageSuggestionProvider }
+ assertNotNull(historyProvider)
+ assertEquals(
+ Companion.METADATA_HISTORY_SUGGESTION_LIMIT,
+ (historyProvider as HistoryStorageSuggestionProvider).getMaxNumberOfSuggestions(),
+ )
+ }
+
+ @Test
+ fun `GIVEN a search not from history and history metadata is enabled and sponsored suggestions are enabled WHEN setting the providers THEN set less suggestions to be shown`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { historyMetadataUIFeature } returns true
+ }
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ searchEngineSource = SearchEngineSource.Shortcut(mockk(relaxed = true)),
+ showSponsoredSuggestions = true,
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val historyProvider = result.firstOrNull { it is CombinedHistorySuggestionProvider }
+ assertNotNull(historyProvider)
+ assertEquals(
+ AwesomeBarView.METADATA_SUGGESTION_LIMIT,
+ (historyProvider as CombinedHistorySuggestionProvider).getMaxNumberOfSuggestions(),
+ )
+ }
+
+ @Test
+ fun `GIVEN a search not from history and history metadata is enabled and sponsored suggestions are disabled WHEN setting the providers THEN set less suggestions to be shown`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { historyMetadataUIFeature } returns true
+ }
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ searchEngineSource = SearchEngineSource.Shortcut(mockk(relaxed = true)),
+ showSponsoredSuggestions = false,
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val historyProvider = result.firstOrNull { it is CombinedHistorySuggestionProvider }
+ assertNotNull(historyProvider)
+ assertEquals(
+ AwesomeBarView.METADATA_SUGGESTION_LIMIT,
+ (historyProvider as CombinedHistorySuggestionProvider).getMaxNumberOfSuggestions(),
+ )
+ }
+
+ @Test
+ fun `GIVEN a search not from history and history metadata is disabled and sponsored suggestions are enabled WHEN setting the providers THEN set less suggestions to be shown`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { historyMetadataUIFeature } returns true
+ }
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ searchEngineSource = SearchEngineSource.Bookmarks(mockk(relaxed = true)),
+ showSponsoredSuggestions = true,
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val historyProvider = result.firstOrNull { it is CombinedHistorySuggestionProvider }
+ assertNotNull(historyProvider)
+ assertEquals(
+ AwesomeBarView.METADATA_SUGGESTION_LIMIT,
+ (historyProvider as CombinedHistorySuggestionProvider).getMaxNumberOfSuggestions(),
+ )
+ }
+
+ @Test
+ fun `GIVEN a search not from history and history metadata disabled WHEN setting the providers THEN set less suggestions to be shown`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { historyMetadataUIFeature } returns true
+ }
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ searchEngineSource = SearchEngineSource.Bookmarks(mockk(relaxed = true)),
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val historyProvider = result.firstOrNull { it is CombinedHistorySuggestionProvider }
+ assertNotNull(historyProvider)
+ assertEquals(
+ AwesomeBarView.METADATA_SUGGESTION_LIMIT,
+ (historyProvider as CombinedHistorySuggestionProvider).getMaxNumberOfSuggestions(),
+ )
+ }
+
+ @Test
+ fun `GIVEN a search that should show filtered history WHEN history metadata is enabled and sponsored suggestions are enabled THEN return a history metadata provider with an engine filter`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { historyMetadataUIFeature } returns true
+ }
+ val url = Uri.parse("test.com")
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ showAllHistorySuggestions = false,
+ searchEngineSource = SearchEngineSource.Shortcut(
+ mockk(relaxed = true) {
+ every { resultsUrl } returns url
+ },
+ ),
+ showSponsoredSuggestions = true,
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val historyProvider = result.firstOrNull { it is CombinedHistorySuggestionProvider }
+ assertNotNull(historyProvider)
+ assertNotNull((historyProvider as CombinedHistorySuggestionProvider).resultsUriFilter)
+ assertEquals(AwesomeBarView.METADATA_SUGGESTION_LIMIT, historyProvider.getMaxNumberOfSuggestions())
+ }
+
+ @Test
+ fun `GIVEN a search that should show filtered history WHEN history metadata is enabled and sponsored suggestions are disabled THEN return a history metadata provider with an engine filter`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { historyMetadataUIFeature } returns true
+ }
+ val url = Uri.parse("test.com")
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ showAllHistorySuggestions = false,
+ searchEngineSource = SearchEngineSource.Shortcut(
+ mockk(relaxed = true) {
+ every { resultsUrl } returns url
+ },
+ ),
+ showSponsoredSuggestions = false,
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val historyProvider = result.firstOrNull { it is CombinedHistorySuggestionProvider }
+ assertNotNull(historyProvider)
+ assertNotNull((historyProvider as CombinedHistorySuggestionProvider).resultsUriFilter)
+ assertEquals(
+ AwesomeBarView.METADATA_SUGGESTION_LIMIT,
+ historyProvider.getMaxNumberOfSuggestions(),
+ )
+ }
+
+ @Test
+ fun `GIVEN the default engine is selected WHEN history metadata is enabled THEN suggestions are disabled in history and bookmark providers`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { historyMetadataUIFeature } returns true
+ }
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ showSearchShortcuts = false,
+ showHistorySuggestionsForCurrentEngine = false,
+ showBookmarksSuggestionsForCurrentEngine = false,
+ showSyncedTabsSuggestionsForCurrentEngine = false,
+ showSessionSuggestionsForCurrentEngine = false,
+ searchEngineSource = SearchEngineSource.Default(mockk(relaxed = true)),
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val combinedHistoryProvider = result.firstOrNull { it is CombinedHistorySuggestionProvider } as CombinedHistorySuggestionProvider
+ assertNotNull(combinedHistoryProvider)
+ assertFalse(combinedHistoryProvider.showEditSuggestion)
+
+ val bookmarkProvider = result.firstOrNull { it is BookmarksStorageSuggestionProvider } as BookmarksStorageSuggestionProvider
+ assertNotNull(bookmarkProvider)
+ assertFalse(bookmarkProvider.showEditSuggestion)
+ }
+
+ @Test
+ fun `GIVEN the default engine is selected WHEN history metadata is disabled THEN suggestions are disabled in history and bookmark providers`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { historyMetadataUIFeature } returns false
+ }
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ showSearchShortcuts = false,
+ showHistorySuggestionsForCurrentEngine = false,
+ showBookmarksSuggestionsForCurrentEngine = false,
+ showSyncedTabsSuggestionsForCurrentEngine = false,
+ showSessionSuggestionsForCurrentEngine = false,
+ searchEngineSource = SearchEngineSource.Default(mockk(relaxed = true)),
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val defaultHistoryProvider = result.firstOrNull { it is HistoryStorageSuggestionProvider } as HistoryStorageSuggestionProvider
+ assertNotNull(defaultHistoryProvider)
+ assertFalse(defaultHistoryProvider.showEditSuggestion)
+
+ val bookmarkProvider = result.firstOrNull { it is BookmarksStorageSuggestionProvider } as BookmarksStorageSuggestionProvider
+ assertNotNull(bookmarkProvider)
+ assertFalse(bookmarkProvider.showEditSuggestion)
+ }
+
+ @Test
+ fun `GIVEN the non default general engine is selected WHEN history metadata is enabled THEN history and bookmark providers are not set`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { historyMetadataUIFeature } returns true
+ }
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ showSearchShortcuts = false,
+ showHistorySuggestionsForCurrentEngine = false,
+ showAllHistorySuggestions = false,
+ showBookmarksSuggestionsForCurrentEngine = false,
+ showAllBookmarkSuggestions = false,
+ showSyncedTabsSuggestionsForCurrentEngine = false,
+ showAllSyncedTabsSuggestions = false,
+ showSessionSuggestionsForCurrentEngine = false,
+ showAllSessionSuggestions = false,
+ searchEngineSource = SearchEngineSource.Shortcut(
+ mockk(relaxed = true) {
+ every { isGeneral } returns true
+ },
+ ),
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val combinedHistoryProvider = result.firstOrNull { it is CombinedHistorySuggestionProvider }
+ assertNull(combinedHistoryProvider)
+
+ val bookmarkProvider = result.firstOrNull { it is BookmarksStorageSuggestionProvider }
+ assertNull(bookmarkProvider)
+ }
+
+ @Test
+ fun `GIVEN the non default general engine is selected WHEN history metadata is disabled THEN history and bookmark providers are not set`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { historyMetadataUIFeature } returns false
+ }
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ showSearchShortcuts = false,
+ showHistorySuggestionsForCurrentEngine = false,
+ showAllHistorySuggestions = false,
+ showBookmarksSuggestionsForCurrentEngine = false,
+ showAllBookmarkSuggestions = false,
+ showSyncedTabsSuggestionsForCurrentEngine = false,
+ showAllSyncedTabsSuggestions = false,
+ showSessionSuggestionsForCurrentEngine = false,
+ showAllSessionSuggestions = false,
+ searchEngineSource = SearchEngineSource.Shortcut(
+ mockk(relaxed = true) {
+ every { isGeneral } returns true
+ },
+ ),
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val defaultHistoryProvider = result.firstOrNull { it is HistoryStorageSuggestionProvider }
+ assertNull(defaultHistoryProvider)
+
+ val bookmarkProvider = result.firstOrNull { it is BookmarksStorageSuggestionProvider }
+ assertNull(bookmarkProvider)
+ }
+
+ @Test
+ fun `GIVEN the non default non general engine is selected WHEN history metadata is enabled THEN suggestions are disabled in history and bookmark providers`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { historyMetadataUIFeature } returns true
+ }
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ showSearchShortcuts = false,
+ showAllHistorySuggestions = false,
+ showAllBookmarkSuggestions = false,
+ showAllSyncedTabsSuggestions = false,
+ showAllSessionSuggestions = false,
+ searchEngineSource = SearchEngineSource.Shortcut(
+ mockk(relaxed = true) {
+ every { isGeneral } returns false
+ },
+ ),
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val combinedHistoryProvider = result.firstOrNull { it is CombinedHistorySuggestionProvider } as CombinedHistorySuggestionProvider
+ assertNotNull(combinedHistoryProvider)
+ assertFalse(combinedHistoryProvider.showEditSuggestion)
+
+ val bookmarkProvider = result.firstOrNull { it is BookmarksStorageSuggestionProvider } as BookmarksStorageSuggestionProvider
+ assertNotNull(bookmarkProvider)
+ assertFalse(bookmarkProvider.showEditSuggestion)
+ }
+
+ @Test
+ fun `GIVEN the non default non general engine is selected WHEN history metadata is disabled THEN suggestions are disabled in history and bookmark providers`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { historyMetadataUIFeature } returns false
+ }
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ showSearchShortcuts = false,
+ showAllHistorySuggestions = false,
+ showAllBookmarkSuggestions = false,
+ showAllSyncedTabsSuggestions = false,
+ showAllSessionSuggestions = false,
+ searchEngineSource = SearchEngineSource.Shortcut(
+ mockk(relaxed = true) {
+ every { isGeneral } returns false
+ },
+ ),
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val defaultHistoryProvider = result.firstOrNull { it is HistoryStorageSuggestionProvider } as HistoryStorageSuggestionProvider
+ assertNotNull(defaultHistoryProvider)
+ assertFalse(defaultHistoryProvider.showEditSuggestion)
+
+ val bookmarkProvider = result.firstOrNull { it is BookmarksStorageSuggestionProvider } as BookmarksStorageSuggestionProvider
+ assertNotNull(bookmarkProvider)
+ assertFalse(bookmarkProvider.showEditSuggestion)
+ }
+
+ @Test
+ fun `GIVEN history is selected WHEN history metadata is enabled THEN suggestions are disabled in history provider, bookmark provider is not set`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { historyMetadataUIFeature } returns true
+ }
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ showSearchShortcuts = false,
+ showSearchTermHistory = false,
+ showHistorySuggestionsForCurrentEngine = false,
+ showBookmarksSuggestionsForCurrentEngine = false,
+ showAllBookmarkSuggestions = false,
+ showSearchSuggestions = false,
+ showSyncedTabsSuggestionsForCurrentEngine = false,
+ showAllSyncedTabsSuggestions = false,
+ showSessionSuggestionsForCurrentEngine = false,
+ showAllSessionSuggestions = false,
+ searchEngineSource = SearchEngineSource.History(mockk(relaxed = true)),
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val combinedHistoryProvider = result.firstOrNull { it is CombinedHistorySuggestionProvider } as CombinedHistorySuggestionProvider
+ assertNotNull(combinedHistoryProvider)
+ assertFalse(combinedHistoryProvider.showEditSuggestion)
+
+ val bookmarkProvider = result.firstOrNull { it is BookmarksStorageSuggestionProvider }
+ assertNull(bookmarkProvider)
+ }
+
+ @Test
+ fun `GIVEN history is selected WHEN history metadata is disabled THEN suggestions are disabled in history provider, bookmark provider is not set`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { historyMetadataUIFeature } returns false
+ }
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ showSearchShortcuts = false,
+ showSearchTermHistory = false,
+ showHistorySuggestionsForCurrentEngine = false,
+ showBookmarksSuggestionsForCurrentEngine = false,
+ showAllBookmarkSuggestions = false,
+ showSearchSuggestions = false,
+ showSyncedTabsSuggestionsForCurrentEngine = false,
+ showAllSyncedTabsSuggestions = false,
+ showSessionSuggestionsForCurrentEngine = false,
+ showAllSessionSuggestions = false,
+ searchEngineSource = SearchEngineSource.History(mockk(relaxed = true)),
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val defaultHistoryProvider = result.firstOrNull { it is HistoryStorageSuggestionProvider } as HistoryStorageSuggestionProvider
+ assertNotNull(defaultHistoryProvider)
+ assertFalse(defaultHistoryProvider.showEditSuggestion)
+
+ val bookmarkProvider = result.firstOrNull { it is BookmarksStorageSuggestionProvider }
+ assertNull(bookmarkProvider)
+ }
+
+ @Test
+ fun `GIVEN tab engine is selected WHEN history metadata is enabled THEN history and bookmark providers are not set`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { historyMetadataUIFeature } returns true
+ }
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ showSearchShortcuts = false,
+ showSearchTermHistory = false,
+ showHistorySuggestionsForCurrentEngine = false,
+ showAllHistorySuggestions = false,
+ showBookmarksSuggestionsForCurrentEngine = false,
+ showAllBookmarkSuggestions = false,
+ showSearchSuggestions = false,
+ showSyncedTabsSuggestionsForCurrentEngine = false,
+ showSessionSuggestionsForCurrentEngine = false,
+ searchEngineSource = SearchEngineSource.Tabs(mockk(relaxed = true)),
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val combinedHistoryProvider = result.firstOrNull { it is CombinedHistorySuggestionProvider }
+ assertNull(combinedHistoryProvider)
+
+ val bookmarkProvider = result.firstOrNull { it is BookmarksStorageSuggestionProvider }
+ assertNull(bookmarkProvider)
+ }
+
+ @Test
+ fun `GIVEN tab engine is selected WHEN history metadata is disabled THEN history and bookmark providers are not set`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { historyMetadataUIFeature } returns false
+ }
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ showSearchShortcuts = false,
+ showSearchTermHistory = false,
+ showHistorySuggestionsForCurrentEngine = false,
+ showAllHistorySuggestions = false,
+ showBookmarksSuggestionsForCurrentEngine = false,
+ showAllBookmarkSuggestions = false,
+ showSearchSuggestions = false,
+ showSyncedTabsSuggestionsForCurrentEngine = false,
+ showSessionSuggestionsForCurrentEngine = false,
+ searchEngineSource = SearchEngineSource.Tabs(mockk(relaxed = true)),
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val defaultHistoryProvider = result.firstOrNull { it is HistoryStorageSuggestionProvider }
+ assertNull(defaultHistoryProvider)
+
+ val bookmarkProvider = result.firstOrNull { it is BookmarksStorageSuggestionProvider }
+ assertNull(bookmarkProvider)
+ }
+
+ @Test
+ fun `GIVEN bookmarks engine is selected WHEN history metadata is enabled THEN history provider is not set, suggestions are disabled in bookmark provider`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { historyMetadataUIFeature } returns true
+ }
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ showSearchShortcuts = false,
+ showSearchTermHistory = false,
+ showHistorySuggestionsForCurrentEngine = false,
+ showAllHistorySuggestions = false,
+ showBookmarksSuggestionsForCurrentEngine = false,
+ showSearchSuggestions = false,
+ showSyncedTabsSuggestionsForCurrentEngine = false,
+ showAllSyncedTabsSuggestions = false,
+ showSessionSuggestionsForCurrentEngine = false,
+ showAllSessionSuggestions = false,
+ searchEngineSource = SearchEngineSource.Bookmarks(mockk(relaxed = true)),
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val combinedHistoryProvider = result.firstOrNull { it is CombinedHistorySuggestionProvider }
+ assertNull(combinedHistoryProvider)
+
+ val bookmarkProvider = result.firstOrNull { it is BookmarksStorageSuggestionProvider } as BookmarksStorageSuggestionProvider
+ assertNotNull(bookmarkProvider)
+ assertFalse(bookmarkProvider.showEditSuggestion)
+ }
+
+ @Test
+ fun `GIVEN bookmarks engine is selected WHEN history metadata is disabled THEN history provider is not set, suggestions are disabled in bookmark provider`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { historyMetadataUIFeature } returns false
+ }
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ showSearchShortcuts = false,
+ showSearchTermHistory = false,
+ showHistorySuggestionsForCurrentEngine = false,
+ showAllHistorySuggestions = false,
+ showBookmarksSuggestionsForCurrentEngine = false,
+ showSearchSuggestions = false,
+ showSyncedTabsSuggestionsForCurrentEngine = false,
+ showAllSyncedTabsSuggestions = false,
+ showSessionSuggestionsForCurrentEngine = false,
+ showAllSessionSuggestions = false,
+ searchEngineSource = SearchEngineSource.Bookmarks(mockk(relaxed = true)),
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val defaultHistoryProvider = result.firstOrNull { it is HistoryStorageSuggestionProvider }
+ assertNull(defaultHistoryProvider)
+
+ val bookmarkProvider = result.firstOrNull { it is BookmarksStorageSuggestionProvider } as BookmarksStorageSuggestionProvider
+ assertNotNull(bookmarkProvider)
+ assertFalse(bookmarkProvider.showEditSuggestion)
+ }
+
+ @Test
+ fun `GIVEN a search that should show filtered history WHEN history metadata is disabled and sponsored suggestions are enabled THEN return a history provider with an engine filter`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { historyMetadataUIFeature } returns false
+ }
+ val url = Uri.parse("test.com")
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ showAllHistorySuggestions = false,
+ searchEngineSource = SearchEngineSource.Shortcut(
+ mockk(relaxed = true) {
+ every { resultsUrl } returns url
+ },
+ ),
+ showSponsoredSuggestions = true,
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val historyProvider = result.firstOrNull { it is HistoryStorageSuggestionProvider }
+ assertNotNull(historyProvider)
+ assertNotNull((historyProvider as HistoryStorageSuggestionProvider).resultsUriFilter)
+ assertEquals(AwesomeBarView.METADATA_SUGGESTION_LIMIT, historyProvider.getMaxNumberOfSuggestions())
+ }
+
+ @Test
+ fun `GIVEN a search that should show filtered history WHEN history metadata is disabled and sponsored suggestions are disabled THEN return a history provider with an engine filter`() {
+ val settings: Settings = mockk(relaxed = true) {
+ every { historyMetadataUIFeature } returns false
+ }
+ val url = Uri.parse("test.com")
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ showAllHistorySuggestions = false,
+ searchEngineSource = SearchEngineSource.Shortcut(
+ mockk(relaxed = true) {
+ every { resultsUrl } returns url
+ },
+ ),
+ showSponsoredSuggestions = false,
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val historyProvider = result.firstOrNull { it is HistoryStorageSuggestionProvider }
+ assertNotNull(historyProvider)
+ assertNotNull((historyProvider as HistoryStorageSuggestionProvider).resultsUriFilter)
+ assertEquals(AwesomeBarView.METADATA_SUGGESTION_LIMIT, historyProvider.getMaxNumberOfSuggestions())
+ }
+
+ @Test
+ fun `GIVEN a search from the default engine WHEN configuring providers THEN add search action and search suggestions providers`() {
+ val settings: Settings = mockk(relaxed = true)
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ showAllHistorySuggestions = false,
+ searchEngineSource = SearchEngineSource.Default(mockk(relaxed = true)),
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ assertEquals(1, result.filterIsInstance<SearchActionProvider>().size)
+ assertEquals(1, result.filterIsInstance<SearchSuggestionProvider>().size)
+ }
+
+ @Test
+ fun `GIVEN a search from a shortcut engine WHEN configuring providers THEN add search action and search suggestions providers`() {
+ val settings: Settings = mockk(relaxed = true)
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ showAllHistorySuggestions = false,
+ searchEngineSource = SearchEngineSource.Default(mockk(relaxed = true)),
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ assertEquals(1, result.filterIsInstance<SearchActionProvider>().size)
+ assertEquals(1, result.filterIsInstance<SearchSuggestionProvider>().size)
+ }
+
+ @Test
+ fun `GIVEN searches from other than default and shortcut engines WHEN configuring providers THEN don't add search action and search suggestion providers`() {
+ val settings: Settings = mockk(relaxed = true)
+ every { activity.settings() } returns settings
+
+ val historyState = getSearchProviderState(
+ searchEngineSource = SearchEngineSource.History(mockk(relaxed = true)),
+ )
+ val bookmarksState = getSearchProviderState(
+ searchEngineSource = SearchEngineSource.Bookmarks(mockk(relaxed = true)),
+ )
+ val tabsState = getSearchProviderState(
+ searchEngineSource = SearchEngineSource.Tabs(mockk(relaxed = true)),
+ )
+ val noneState = getSearchProviderState()
+
+ val historyResult = awesomeBarView.getProvidersToAdd(historyState)
+ val bookmarksResult = awesomeBarView.getProvidersToAdd(bookmarksState)
+ val tabsResult = awesomeBarView.getProvidersToAdd(tabsState)
+ val noneResult = awesomeBarView.getProvidersToAdd(noneState)
+ val allResults = historyResult + bookmarksResult + tabsResult + noneResult
+
+ assertEquals(0, allResults.filterIsInstance<SearchActionProvider>().size)
+ assertEquals(0, allResults.filterIsInstance<SearchSuggestionProvider>().size)
+ }
+
+ @Test
+ fun `GIVEN normal browsing mode and needing to show all local tabs suggestions and sponsored suggestions are enabled WHEN configuring providers THEN add the tabs provider`() {
+ val settings: Settings = mockk(relaxed = true)
+ val url = Uri.parse("https://www.test.com")
+ every { activity.settings() } returns settings
+ every { activity.browsingModeManager.mode } returns BrowsingMode.Normal
+ val state = getSearchProviderState(
+ showSessionSuggestionsForCurrentEngine = false,
+ searchEngineSource = SearchEngineSource.Shortcut(
+ mockk(relaxed = true) {
+ every { resultsUrl } returns url
+ },
+ ),
+ showSponsoredSuggestions = true,
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val localSessionsProviders = result.filterIsInstance<SessionSuggestionProvider>()
+ assertEquals(1, localSessionsProviders.size)
+ assertNull(localSessionsProviders[0].resultsUriFilter)
+ }
+
+ @Test
+ fun `GIVEN normal browsing mode and needing to show all local tabs suggestions and sponsored suggestions are disabled WHEN configuring providers THEN add the tabs provider`() {
+ val settings: Settings = mockk(relaxed = true)
+ val url = Uri.parse("https://www.test.com")
+ every { activity.settings() } returns settings
+ every { activity.browsingModeManager.mode } returns BrowsingMode.Normal
+ val state = getSearchProviderState(
+ showSessionSuggestionsForCurrentEngine = false,
+ searchEngineSource = SearchEngineSource.Shortcut(
+ mockk(relaxed = true) {
+ every { resultsUrl } returns url
+ },
+ ),
+ showSponsoredSuggestions = false,
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val localSessionsProviders = result.filterIsInstance<SessionSuggestionProvider>()
+ assertEquals(1, localSessionsProviders.size)
+ assertNull(localSessionsProviders[0].resultsUriFilter)
+ }
+
+ @Test
+ fun `GIVEN normal browsing mode and needing to show filtered local tabs suggestions WHEN configuring providers THEN add the tabs provider with an engine filter`() {
+ val settings: Settings = mockk(relaxed = true)
+ val url = Uri.parse("https://www.test.com")
+ every { activity.settings() } returns settings
+ every { activity.browsingModeManager.mode } returns BrowsingMode.Normal
+ val state = getSearchProviderState(
+ showAllSessionSuggestions = false,
+ searchEngineSource = SearchEngineSource.Shortcut(
+ mockk(relaxed = true) {
+ every { resultsUrl } returns url
+ },
+ ),
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val localSessionsProviders = result.filterIsInstance<SessionSuggestionProvider>()
+ assertEquals(1, localSessionsProviders.size)
+ assertNotNull(localSessionsProviders[0].resultsUriFilter)
+ }
+
+ @Test
+ fun `GIVEN private browsing mode and needing to show tabs suggestions WHEN configuring providers THEN don't add the tabs provider`() {
+ val settings: Settings = mockk(relaxed = true)
+ every { activity.settings() } returns settings
+ every { activity.browsingModeManager.mode } returns BrowsingMode.Private
+ val state = getSearchProviderState(
+ searchEngineSource = SearchEngineSource.Shortcut(mockk(relaxed = true)),
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ assertEquals(0, result.filterIsInstance<SessionSuggestionProvider>().size)
+ }
+
+ @Test
+ fun `GIVEN needing to show all synced tabs suggestions and sponsored suggestions are enabled WHEN configuring providers THEN add the synced tabs provider with a sponsored filter`() {
+ val settings: Settings = mockk(relaxed = true)
+ val url = Uri.parse("https://www.test.com")
+ every { activity.settings() } returns settings
+ every { activity.browsingModeManager.mode } returns BrowsingMode.Normal
+ val state = getSearchProviderState(
+ showSyncedTabsSuggestionsForCurrentEngine = false,
+ searchEngineSource = SearchEngineSource.Shortcut(
+ mockk(relaxed = true) {
+ every { resultsUrl } returns url
+ },
+ ),
+ showSponsoredSuggestions = true,
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val localSessionsProviders = result.filterIsInstance<SyncedTabsStorageSuggestionProvider>()
+ assertEquals(1, localSessionsProviders.size)
+ assertNotNull(localSessionsProviders[0].resultsUrlFilter)
+ }
+
+ @Test
+ fun `GIVEN needing to show filtered synced tabs suggestions and sponsored suggestions are enabled WHEN configuring providers THEN add the synced tabs provider with an engine filter`() {
+ val settings: Settings = mockk(relaxed = true)
+ val url = Uri.parse("https://www.test.com")
+ every { activity.settings() } returns settings
+ every { activity.browsingModeManager.mode } returns BrowsingMode.Normal
+ val state = getSearchProviderState(
+ showAllSyncedTabsSuggestions = false,
+ searchEngineSource = SearchEngineSource.Shortcut(
+ mockk(relaxed = true) {
+ every { resultsUrl } returns url
+ },
+ ),
+ showSponsoredSuggestions = true,
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val localSessionsProviders = result.filterIsInstance<SyncedTabsStorageSuggestionProvider>()
+ assertEquals(1, localSessionsProviders.size)
+ assertNotNull(localSessionsProviders[0].resultsUrlFilter)
+ }
+
+ @Test
+ fun `GIVEN needing to show all synced tabs suggestions and sponsored suggestions are disabled WHEN configuring providers THEN add the synced tabs provider`() {
+ val settings: Settings = mockk(relaxed = true)
+ val url = Uri.parse("https://www.test.com")
+ every { activity.settings() } returns settings
+ every { activity.browsingModeManager.mode } returns BrowsingMode.Normal
+ val state = getSearchProviderState(
+ showSyncedTabsSuggestionsForCurrentEngine = false,
+ searchEngineSource = SearchEngineSource.Shortcut(
+ mockk(relaxed = true) {
+ every { resultsUrl } returns url
+ },
+ ),
+ showSponsoredSuggestions = false,
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val localSessionsProviders = result.filterIsInstance<SyncedTabsStorageSuggestionProvider>()
+ assertEquals(1, localSessionsProviders.size)
+ assertNull(localSessionsProviders[0].resultsUrlFilter)
+ }
+
+ @Test
+ fun `GIVEN needing to show all bookmarks suggestions and sponsored suggestions are enabled WHEN configuring providers THEN add the bookmarks provider with a sponsored filter`() {
+ val settings: Settings = mockk(relaxed = true)
+ val url = Uri.parse("https://www.test.com")
+ every { activity.settings() } returns settings
+ every { activity.browsingModeManager.mode } returns BrowsingMode.Normal
+ val state = getSearchProviderState(
+ showBookmarksSuggestionsForCurrentEngine = false,
+ searchEngineSource = SearchEngineSource.Shortcut(
+ mockk(relaxed = true) {
+ every { resultsUrl } returns url
+ },
+ ),
+ showSponsoredSuggestions = true,
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val localSessionsProviders = result.filterIsInstance<BookmarksStorageSuggestionProvider>()
+ assertEquals(1, localSessionsProviders.size)
+ assertNotNull(localSessionsProviders[0].resultsUriFilter)
+ }
+
+ @Test
+ fun `GIVEN needing to show all bookmarks suggestions and sponsored suggestions are disabled WHEN configuring providers THEN add the bookmarks provider`() {
+ val settings: Settings = mockk(relaxed = true)
+ val url = Uri.parse("https://www.test.com")
+ every { activity.settings() } returns settings
+ every { activity.browsingModeManager.mode } returns BrowsingMode.Normal
+ val state = getSearchProviderState(
+ showBookmarksSuggestionsForCurrentEngine = false,
+ searchEngineSource = SearchEngineSource.Shortcut(
+ mockk(relaxed = true) {
+ every { resultsUrl } returns url
+ },
+ ),
+ showSponsoredSuggestions = false,
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val localSessionsProviders = result.filterIsInstance<BookmarksStorageSuggestionProvider>()
+ assertEquals(1, localSessionsProviders.size)
+ assertNull(localSessionsProviders[0].resultsUriFilter)
+ }
+
+ @Test
+ fun `GIVEN needing to show filtered bookmarks suggestions WHEN configuring providers THEN add the bookmarks provider with an engine filter`() {
+ val settings: Settings = mockk(relaxed = true)
+ val url = Uri.parse("https://www.test.com")
+ every { activity.settings() } returns settings
+ every { activity.browsingModeManager.mode } returns BrowsingMode.Normal
+ val state = getSearchProviderState(
+ showAllBookmarkSuggestions = false,
+ searchEngineSource = SearchEngineSource.Shortcut(
+ mockk(relaxed = true) {
+ every { resultsUrl } returns url
+ },
+ ),
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val localSessionsProviders = result.filterIsInstance<BookmarksStorageSuggestionProvider>()
+ assertEquals(1, localSessionsProviders.size)
+ assertNotNull(localSessionsProviders[0].resultsUriFilter)
+ }
+
+ @Test
+ fun `GIVEN a search is made by the user WHEN configuring providers THEN search engine suggestion provider should always be added`() {
+ val settings: Settings = mockk(relaxed = true)
+ every { activity.settings() } returns settings
+ val state = getSearchProviderState(
+ searchEngineSource = SearchEngineSource.Default(mockk(relaxed = true)),
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ assertEquals(1, result.filterIsInstance<SearchEngineSuggestionProvider>().size)
+ }
+
+ @Test
+ fun `GIVEN a search from the default engine with all suggestions asked and sponsored suggestions are enabled WHEN configuring providers THEN add them all`() {
+ val settings: Settings = mockk(relaxed = true)
+ val url = Uri.parse("https://www.test.com")
+ every { activity.settings() } returns settings
+ every { activity.browsingModeManager.mode } returns BrowsingMode.Normal
+ val state = getSearchProviderState(
+ searchEngineSource = SearchEngineSource.Default(
+ mockk(relaxed = true) {
+ every { resultsUrl } returns url
+ },
+ ),
+ showSponsoredSuggestions = true,
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val historyProviders: List<HistoryStorageSuggestionProvider> = result.filterIsInstance<HistoryStorageSuggestionProvider>()
+ assertEquals(2, historyProviders.size)
+ assertNotNull(historyProviders[0].resultsUriFilter) // the general history provider
+ assertNotNull(historyProviders[1].resultsUriFilter) // the filtered history provider
+ val bookmarksProviders: List<BookmarksStorageSuggestionProvider> = result.filterIsInstance<BookmarksStorageSuggestionProvider>()
+ assertEquals(2, bookmarksProviders.size)
+ assertNotNull(bookmarksProviders[0].resultsUriFilter) // the general bookmarks provider
+ assertNotNull(bookmarksProviders[1].resultsUriFilter) // the filtered bookmarks provider
+ assertEquals(1, result.filterIsInstance<SearchActionProvider>().size)
+ assertEquals(1, result.filterIsInstance<SearchSuggestionProvider>().size)
+ val syncedTabsProviders: List<SyncedTabsStorageSuggestionProvider> = result.filterIsInstance<SyncedTabsStorageSuggestionProvider>()
+ assertEquals(2, syncedTabsProviders.size)
+ assertNotNull(syncedTabsProviders[0].resultsUrlFilter) // the general synced tabs provider
+ assertNotNull(syncedTabsProviders[1].resultsUrlFilter) // the filtered synced tabs provider
+ val localTabsProviders: List<SessionSuggestionProvider> = result.filterIsInstance<SessionSuggestionProvider>()
+ assertEquals(2, localTabsProviders.size)
+ assertNull(localTabsProviders[0].resultsUriFilter) // the general tabs provider
+ assertNotNull(localTabsProviders[1].resultsUriFilter) // the filtered tabs provider
+ assertEquals(1, result.filterIsInstance<SearchEngineSuggestionProvider>().size)
+ }
+
+ @Test
+ fun `GIVEN a search from the default engine with all suggestions asked and sponsored suggestions are disabled WHEN configuring providers THEN add them all`() {
+ val settings: Settings = mockk(relaxed = true)
+ val url = Uri.parse("https://www.test.com")
+ every { activity.settings() } returns settings
+ every { activity.browsingModeManager.mode } returns BrowsingMode.Normal
+ val state = getSearchProviderState(
+ searchEngineSource = SearchEngineSource.Default(
+ mockk(relaxed = true) {
+ every { resultsUrl } returns url
+ },
+ ),
+ showSponsoredSuggestions = false,
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val historyProviders: List<HistoryStorageSuggestionProvider> = result.filterIsInstance<HistoryStorageSuggestionProvider>()
+ assertEquals(2, historyProviders.size)
+ assertNull(historyProviders[0].resultsUriFilter) // the general history provider
+ assertNotNull(historyProviders[1].resultsUriFilter) // the filtered history provider
+ val bookmarksProviders: List<BookmarksStorageSuggestionProvider> = result.filterIsInstance<BookmarksStorageSuggestionProvider>()
+ assertEquals(2, bookmarksProviders.size)
+ assertNull(bookmarksProviders[0].resultsUriFilter) // the general bookmarks provider
+ assertNotNull(bookmarksProviders[1].resultsUriFilter) // the filtered bookmarks provider
+ assertEquals(1, result.filterIsInstance<SearchActionProvider>().size)
+ assertEquals(1, result.filterIsInstance<SearchSuggestionProvider>().size)
+ val syncedTabsProviders: List<SyncedTabsStorageSuggestionProvider> = result.filterIsInstance<SyncedTabsStorageSuggestionProvider>()
+ assertEquals(2, syncedTabsProviders.size)
+ assertNull(syncedTabsProviders[0].resultsUrlFilter) // the general synced tabs provider
+ assertNotNull(syncedTabsProviders[1].resultsUrlFilter) // the filtered synced tabs provider
+ val localTabsProviders: List<SessionSuggestionProvider> = result.filterIsInstance<SessionSuggestionProvider>()
+ assertEquals(2, localTabsProviders.size)
+ assertNull(localTabsProviders[0].resultsUriFilter) // the general tabs provider
+ assertNotNull(localTabsProviders[1].resultsUriFilter) // the filtered tabs provider
+ assertEquals(1, result.filterIsInstance<SearchEngineSuggestionProvider>().size)
+ }
+
+ @Test
+ fun `GIVEN a search from the default engine with no suggestions asked WHEN configuring providers THEN add only search engine suggestion provider`() {
+ val settings: Settings = mockk(relaxed = true)
+ every { activity.settings() } returns settings
+ every { activity.browsingModeManager.mode } returns BrowsingMode.Normal
+ val state = getSearchProviderState(
+ showHistorySuggestionsForCurrentEngine = false,
+ showSearchShortcuts = false,
+ showAllHistorySuggestions = false,
+ showBookmarksSuggestionsForCurrentEngine = false,
+ showAllBookmarkSuggestions = false,
+ showSearchSuggestions = false,
+ showSyncedTabsSuggestionsForCurrentEngine = false,
+ showAllSyncedTabsSuggestions = false,
+ showSessionSuggestionsForCurrentEngine = false,
+ showAllSessionSuggestions = false,
+ searchEngineSource = SearchEngineSource.Default(mockk(relaxed = true)),
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ assertEquals(0, result.filterIsInstance<HistoryStorageSuggestionProvider>().size)
+ assertEquals(0, result.filterIsInstance<BookmarksStorageSuggestionProvider>().size)
+ assertEquals(0, result.filterIsInstance<SearchActionProvider>().size)
+ assertEquals(0, result.filterIsInstance<SearchSuggestionProvider>().size)
+ assertEquals(0, result.filterIsInstance<SyncedTabsStorageSuggestionProvider>().size)
+ assertEquals(0, result.filterIsInstance<SessionSuggestionProvider>().size)
+ assertEquals(1, result.filterIsInstance<SearchEngineSuggestionProvider>().size)
+ }
+
+ @Test
+ fun `GIVEN sponsored suggestions are enabled WHEN configuring providers THEN add the Firefox Suggest suggestion provider`() {
+ val settings: Settings = mockk(relaxed = true)
+ every { activity.settings() } returns settings
+ val awesomeBarView = AwesomeBarView(
+ activity = activity,
+ interactor = mockk(),
+ view = mockk(),
+ fromHomeFragment = false,
+ )
+ val state = getSearchProviderState(
+ showSponsoredSuggestions = true,
+ showNonSponsoredSuggestions = false,
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val fxSuggestProvider = result.firstOrNull { it is FxSuggestSuggestionProvider }
+ assertNotNull(fxSuggestProvider)
+ }
+
+ @Test
+ fun `GIVEN non-sponsored suggestions are enabled WHEN configuring providers THEN add the Firefox Suggest suggestion provider`() {
+ val settings: Settings = mockk(relaxed = true)
+ every { activity.settings() } returns settings
+ val awesomeBarView = AwesomeBarView(
+ activity = activity,
+ interactor = mockk(),
+ view = mockk(),
+ fromHomeFragment = false,
+ )
+ val state = getSearchProviderState(
+ showSponsoredSuggestions = false,
+ showNonSponsoredSuggestions = true,
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val fxSuggestProvider = result.firstOrNull { it is FxSuggestSuggestionProvider }
+ assertNotNull(fxSuggestProvider)
+ }
+
+ @Test
+ fun `GIVEN sponsored and non-sponsored suggestions are enabled WHEN configuring providers THEN add the Firefox Suggest suggestion provider`() {
+ val settings: Settings = mockk(relaxed = true)
+ every { activity.settings() } returns settings
+ val awesomeBarView = AwesomeBarView(
+ activity = activity,
+ interactor = mockk(),
+ view = mockk(),
+ fromHomeFragment = false,
+ )
+ val state = getSearchProviderState(
+ showSponsoredSuggestions = true,
+ showNonSponsoredSuggestions = true,
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val fxSuggestProvider = result.firstOrNull { it is FxSuggestSuggestionProvider }
+ assertNotNull(fxSuggestProvider)
+ }
+
+ @Test
+ fun `GIVEN sponsored and non-sponsored suggestions are disabled WHEN configuring providers THEN don't add the Firefox Suggest suggestion provider`() {
+ val settings: Settings = mockk(relaxed = true)
+ every { activity.settings() } returns settings
+ val awesomeBarView = AwesomeBarView(
+ activity = activity,
+ interactor = mockk(),
+ view = mockk(),
+ fromHomeFragment = false,
+ )
+ val state = getSearchProviderState(
+ showSponsoredSuggestions = false,
+ showNonSponsoredSuggestions = false,
+ )
+
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ val fxSuggestProvider = result.firstOrNull { it is FxSuggestSuggestionProvider }
+ assertNull(fxSuggestProvider)
+ }
+
+ @Test
+ fun `GIVEN a valid search engine and history metadata enabled WHEN creating a history provider for that engine THEN return a history metadata provider with engine filter`() {
+ val settings: Settings = mockk {
+ every { historyMetadataUIFeature } returns true
+ }
+ every { activity.settings() } returns settings
+ val searchEngineSource = SearchEngineSource.Shortcut(mockk(relaxed = true))
+ val state = getSearchProviderState(searchEngineSource = searchEngineSource)
+
+ val result = awesomeBarView.getHistoryProvider(
+ filter = awesomeBarView.getFilterForCurrentEngineResults(state),
+ )
+
+ assertNotNull(result)
+ assertTrue(result is CombinedHistorySuggestionProvider)
+ assertNotNull((result as CombinedHistorySuggestionProvider).resultsUriFilter)
+ assertEquals(AwesomeBarView.METADATA_SUGGESTION_LIMIT, result.getMaxNumberOfSuggestions())
+ }
+
+ @Test
+ fun `GIVEN a valid search engine and history metadata disabled WHEN creating a history provider for that engine THEN return a history metadata provider with an engine filter`() {
+ val settings: Settings = mockk {
+ every { historyMetadataUIFeature } returns false
+ }
+ every { activity.settings() } returns settings
+ val searchEngineSource = SearchEngineSource.Shortcut(mockk(relaxed = true))
+ val state = getSearchProviderState(
+ searchEngineSource = searchEngineSource,
+ )
+
+ val result = awesomeBarView.getHistoryProvider(
+ filter = awesomeBarView.getFilterForCurrentEngineResults(state),
+ )
+
+ assertNotNull(result)
+ assertTrue(result is HistoryStorageSuggestionProvider)
+ assertNotNull((result as HistoryStorageSuggestionProvider).resultsUriFilter)
+ assertEquals(AwesomeBarView.METADATA_SUGGESTION_LIMIT, result.getMaxNumberOfSuggestions())
+ }
+
+ @Test
+ fun `GIVEN a search engine is not available WHEN asking for a search term provider THEN return null`() {
+ val searchEngineSource: SearchEngineSource = SearchEngineSource.None
+
+ val result = awesomeBarView.getSearchTermSuggestionsProvider(searchEngineSource)
+
+ assertNull(result)
+ }
+
+ @Test
+ fun `GIVEN a search engine is available WHEN asking for a search term provider THEN return a valid provider`() {
+ val searchEngineSource = SearchEngineSource.Default(mockk(relaxed = true))
+
+ val result = awesomeBarView.getSearchTermSuggestionsProvider(searchEngineSource)
+
+ assertTrue(result is SearchTermSuggestionsProvider)
+ }
+
+ @Test
+ fun `GIVEN the default search engine WHEN asking for a search term provider THEN the provider should have a suggestions header`() {
+ val engine: SearchEngine = mockk {
+ every { name } returns "Test"
+ }
+ val searchEngineSource = SearchEngineSource.Default(engine)
+ every { AwesomeBarView.Companion.getString(any(), any(), any()) } answers {
+ "Search Test"
+ }
+
+ mockkStatic("mozilla.components.browser.state.state.SearchStateKt") {
+ every { any<SearchState>().selectedOrDefaultSearchEngine } returns engine
+
+ val result = awesomeBarView.getSearchTermSuggestionsProvider(searchEngineSource)
+
+ assertTrue(result is SearchTermSuggestionsProvider)
+ assertEquals("Search Test", result?.groupTitle())
+ }
+ }
+
+ @Test
+ fun `GIVEN a shortcut search engine selected WHEN asking for a search term provider THEN the provider should not have a suggestions header`() {
+ val defaultEngine: SearchEngine = mockk {
+ every { name } returns "Test"
+ }
+ val otherEngine: SearchEngine = mockk {
+ every { name } returns "Other"
+ }
+ val searchEngineSource = SearchEngineSource.Shortcut(otherEngine)
+ every { AwesomeBarView.Companion.getString(any(), any(), any()) } answers {
+ "Search Test"
+ }
+
+ mockkStatic("mozilla.components.browser.state.state.SearchStateKt") {
+ every { any<SearchState>().selectedOrDefaultSearchEngine } returns defaultEngine
+
+ val result = awesomeBarView.getSearchTermSuggestionsProvider(searchEngineSource)
+
+ assertTrue(result is SearchTermSuggestionsProvider)
+ assertNull(result?.groupTitle())
+ }
+ }
+
+ @Test
+ fun `GIVEN the default search engine is unknown WHEN asking for a search term provider THEN the provider should not have a suggestions header`() {
+ val defaultEngine: SearchEngine? = null
+ val otherEngine: SearchEngine = mockk {
+ every { name } returns "Other"
+ }
+ val searchEngineSource = SearchEngineSource.Shortcut(otherEngine)
+ every { AwesomeBarView.Companion.getString(any(), any(), any()) } answers {
+ "Search Test"
+ }
+
+ mockkStatic("mozilla.components.browser.state.state.SearchStateKt") {
+ every { any<SearchState>().selectedOrDefaultSearchEngine } returns defaultEngine
+
+ val result = awesomeBarView.getSearchTermSuggestionsProvider(searchEngineSource)
+
+ assertTrue(result is SearchTermSuggestionsProvider)
+ assertNull(result?.groupTitle())
+ }
+ }
+
+ @Test
+ fun `GIVEN history search term suggestions disabled WHEN getting suggestions providers THEN don't search term provider of past searches`() {
+ every { activity.settings() } returns mockk(relaxed = true)
+ val state = getSearchProviderState(
+ searchEngineSource = SearchEngineSource.Default(mockk(relaxed = true)),
+ showSearchTermHistory = false,
+ )
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ assertEquals(0, result.filterIsInstance<SearchTermSuggestionsProvider>().size)
+ }
+
+ @Test
+ fun `GIVEN history search term suggestions enabled WHEN getting suggestions providers THEN add a search term provider of past searches`() {
+ every { activity.settings() } returns mockk(relaxed = true)
+ val state = getSearchProviderState(
+ searchEngineSource = SearchEngineSource.Default(mockk(relaxed = true)),
+ showSearchTermHistory = true,
+ )
+ val result = awesomeBarView.getProvidersToAdd(state)
+
+ assertEquals(1, result.filterIsInstance<SearchTermSuggestionsProvider>().size)
+ }
+
+ @Test
+ fun `GIVEN sponsored suggestions are enabled WHEN getting a filter to exclude sponsored suggestions THEN return the filter`() {
+ every { activity.settings() } returns mockk(relaxed = true) {
+ every { frecencyFilterQuery } returns "query=value"
+ }
+ val state = getSearchProviderState(
+ searchEngineSource = SearchEngineSource.Default(mockk(relaxed = true)),
+ showSponsoredSuggestions = true,
+ )
+ val filter = awesomeBarView.getFilterToExcludeSponsoredResults(state)
+
+ assertEquals(AwesomeBarView.SearchResultFilter.ExcludeSponsored("query=value"), filter)
+ }
+
+ @Test
+ fun `GIVEN sponsored suggestions are disabled WHEN getting a filter to exclude sponsored suggestions THEN return null`() {
+ every { activity.settings() } returns mockk(relaxed = true) {
+ every { frecencyFilterQuery } returns "query=value"
+ }
+ val state = getSearchProviderState(
+ searchEngineSource = SearchEngineSource.Default(mockk(relaxed = true)),
+ showSponsoredSuggestions = false,
+ )
+ val filter = awesomeBarView.getFilterToExcludeSponsoredResults(state)
+
+ assertNull(filter)
+ }
+
+ @Test
+ fun `GIVEN a sponsored query parameter and a sponsored filter WHEN a URL contains the sponsored query parameter THEN that URL should be excluded`() {
+ every { activity.settings() } returns mockk(relaxed = true) {
+ every { frecencyFilterQuery } returns "query=value"
+ }
+ val state = getSearchProviderState(
+ searchEngineSource = SearchEngineSource.Default(mockk(relaxed = true)),
+ showSponsoredSuggestions = true,
+ )
+ val filter = requireNotNull(awesomeBarView.getFilterToExcludeSponsoredResults(state))
+
+ assertFalse(filter.shouldIncludeUri(Uri.parse("http://example.com?query=value")))
+ assertFalse(filter.shouldIncludeUri(Uri.parse("http://example.com/a?query=value")))
+ assertFalse(filter.shouldIncludeUri(Uri.parse("http://example.com/a?b=c&query=value")))
+ assertFalse(filter.shouldIncludeUri(Uri.parse("http://example.com/a?b=c&query=value&d=e")))
+
+ assertFalse(filter.shouldIncludeUrl("http://example.com?query=value"))
+ assertFalse(filter.shouldIncludeUrl("http://example.com/a?query=value"))
+ assertFalse(filter.shouldIncludeUrl("http://example.com/a?b=c&query=value"))
+ assertFalse(filter.shouldIncludeUrl("http://example.com/a?b=c&query=value&d=e"))
+ }
+
+ @Test
+ fun `GIVEN a sponsored query parameter and a sponsored filter WHEN a URL does not contain the sponsored query parameter THEN that URL should be included`() {
+ every { activity.settings() } returns mockk(relaxed = true) {
+ every { frecencyFilterQuery } returns "query=value"
+ }
+ val state = getSearchProviderState(
+ searchEngineSource = SearchEngineSource.Default(mockk(relaxed = true)),
+ showSponsoredSuggestions = true,
+ )
+ val filter = requireNotNull(awesomeBarView.getFilterToExcludeSponsoredResults(state))
+
+ assertTrue(filter.shouldIncludeUri(Uri.parse("http://example.com")))
+ assertTrue(filter.shouldIncludeUri(Uri.parse("http://example.com?query")))
+ assertTrue(filter.shouldIncludeUri(Uri.parse("http://example.com/a")))
+ assertTrue(filter.shouldIncludeUri(Uri.parse("http://example.com/a?b")))
+ assertTrue(filter.shouldIncludeUri(Uri.parse("http://example.com/a?b&c=d")))
+
+ assertTrue(filter.shouldIncludeUrl("http://example.com"))
+ assertTrue(filter.shouldIncludeUrl("http://example.com?query"))
+ assertTrue(filter.shouldIncludeUrl("http://example.com/a"))
+ assertTrue(filter.shouldIncludeUrl("http://example.com/a?b"))
+ assertTrue(filter.shouldIncludeUrl("http://example.com/a?b&c=d"))
+ }
+
+ @Test
+ fun `GIVEN an engine with a results URL and an engine filter WHEN a URL matches the results URL THEN that URL should be included`() {
+ val url = Uri.parse("http://test.com")
+ val state = getSearchProviderState(
+ searchEngineSource = SearchEngineSource.Shortcut(
+ mockk(relaxed = true) {
+ every { resultsUrl } returns url
+ },
+ ),
+ )
+
+ val filter = requireNotNull(awesomeBarView.getFilterForCurrentEngineResults(state))
+
+ assertTrue(filter.shouldIncludeUri(Uri.parse("http://test.com")))
+ assertTrue(filter.shouldIncludeUri(Uri.parse("http://test.com/a")))
+ assertTrue(filter.shouldIncludeUri(Uri.parse("http://mobile.test.com")))
+ assertTrue(filter.shouldIncludeUri(Uri.parse("http://mobile.test.com/a")))
+
+ assertTrue(filter.shouldIncludeUrl("http://test.com"))
+ assertTrue(filter.shouldIncludeUrl("http://test.com/a"))
+ }
+
+ @Test
+ fun `GIVEN an engine with a results URL and an engine filter WHEN a URL does not match the results URL THEN that URL should be excluded`() {
+ val url = Uri.parse("http://test.com")
+ val state = getSearchProviderState(
+ searchEngineSource = SearchEngineSource.Shortcut(
+ mockk(relaxed = true) {
+ every { resultsUrl } returns url
+ },
+ ),
+ )
+
+ val filter = requireNotNull(awesomeBarView.getFilterForCurrentEngineResults(state))
+
+ assertFalse(filter.shouldIncludeUri(Uri.parse("http://other.com")))
+ assertFalse(filter.shouldIncludeUri(Uri.parse("http://subdomain.test.com")))
+
+ assertFalse(filter.shouldIncludeUrl("http://mobile.test.com"))
+ }
+}
+
+/**
+ * Get a default [SearchProviderState] that by default will ask for all types of suggestions.
+ */
+private fun getSearchProviderState(
+ showSearchShortcuts: Boolean = true,
+ showSearchTermHistory: Boolean = true,
+ showHistorySuggestionsForCurrentEngine: Boolean = true,
+ showAllHistorySuggestions: Boolean = true,
+ showBookmarksSuggestionsForCurrentEngine: Boolean = true,
+ showAllBookmarkSuggestions: Boolean = true,
+ showSearchSuggestions: Boolean = true,
+ showSyncedTabsSuggestionsForCurrentEngine: Boolean = true,
+ showAllSyncedTabsSuggestions: Boolean = true,
+ showSessionSuggestionsForCurrentEngine: Boolean = true,
+ showAllSessionSuggestions: Boolean = true,
+ searchEngineSource: SearchEngineSource = SearchEngineSource.None,
+ showSponsoredSuggestions: Boolean = true,
+ showNonSponsoredSuggestions: Boolean = true,
+) = SearchProviderState(
+ showSearchShortcuts = showSearchShortcuts,
+ showSearchTermHistory = showSearchTermHistory,
+ showHistorySuggestionsForCurrentEngine = showHistorySuggestionsForCurrentEngine,
+ showAllHistorySuggestions = showAllHistorySuggestions,
+ showBookmarksSuggestionsForCurrentEngine = showBookmarksSuggestionsForCurrentEngine,
+ showAllBookmarkSuggestions = showAllBookmarkSuggestions,
+ showSearchSuggestions = showSearchSuggestions,
+ showSyncedTabsSuggestionsForCurrentEngine = showSyncedTabsSuggestionsForCurrentEngine,
+ showAllSyncedTabsSuggestions = showAllSyncedTabsSuggestions,
+ showSessionSuggestionsForCurrentEngine = showSessionSuggestionsForCurrentEngine,
+ showAllSessionSuggestions = showAllSessionSuggestions,
+ showSponsoredSuggestions = showSponsoredSuggestions,
+ showNonSponsoredSuggestions = showNonSponsoredSuggestions,
+ searchEngineSource = searchEngineSource,
+)
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/awesomebar/ShortcutsSuggestionProviderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/awesomebar/ShortcutsSuggestionProviderTest.kt
new file mode 100644
index 0000000000..6c88f0bb0d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/awesomebar/ShortcutsSuggestionProviderTest.kt
@@ -0,0 +1,113 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.search.awesomebar
+
+import android.content.Context
+import androidx.appcompat.content.res.AppCompatResources
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkStatic
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.state.search.SearchEngine
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.SearchState
+import mozilla.components.browser.state.store.BrowserStore
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.R
+
+class ShortcutsSuggestionProviderTest {
+
+ private lateinit var context: Context
+
+ @Before
+ fun setup() {
+ mockkStatic(AppCompatResources::class)
+ context = mockk {
+ every { getString(R.string.search_shortcuts_engine_settings) } returns "Search engine settings"
+ }
+
+ every { AppCompatResources.getDrawable(context, R.drawable.mozac_ic_settings_24) } returns null
+ }
+
+ @After
+ fun teardown() {
+ unmockkStatic(AppCompatResources::class)
+ }
+
+ @Test
+ fun `returns suggestions from search engine provider`() = runTest {
+ val engineOne = mockk<SearchEngine> {
+ every { id } returns "1"
+ every { name } returns "EngineOne"
+ every { icon } returns mockk()
+ }
+ val engineTwo = mockk<SearchEngine> {
+ every { id } returns "2"
+ every { name } returns "EngineTwo"
+ every { icon } returns mockk()
+ }
+ val store = BrowserStore(
+ BrowserState(
+ search = SearchState(
+ regionSearchEngines = listOf(engineOne, engineTwo),
+ ),
+ ),
+ )
+ val provider = ShortcutsSuggestionProvider(store, context, {}, {})
+
+ val suggestions = provider.onInputChanged("")
+
+ assertEquals(3, suggestions.size)
+
+ assertEquals(provider, suggestions[0].provider)
+ assertEquals(engineOne.id, suggestions[0].id)
+ assertEquals(engineOne.icon, suggestions[0].icon)
+ assertEquals(engineOne.name, suggestions[0].title)
+
+ assertEquals(provider, suggestions[1].provider)
+ assertEquals(engineTwo.id, suggestions[1].id)
+ assertEquals(engineTwo.icon, suggestions[1].icon)
+ assertEquals(engineTwo.name, suggestions[1].title)
+
+ assertEquals(provider, suggestions[2].provider)
+ assertEquals("Search engine settings", suggestions[2].id)
+ assertEquals("Search engine settings", suggestions[2].title)
+ }
+
+ @Test
+ fun `callbacks are triggered when suggestions are clicked`() = runTest {
+ val engineOne = mockk<SearchEngine>(relaxed = true)
+ val store = BrowserStore(
+ BrowserState(
+ search = SearchState(
+ regionSearchEngines = listOf(engineOne),
+ ),
+ ),
+ )
+
+ var selectEngine: SearchEngine? = null
+ var selectShortcutEngineSettingsChanged = false
+ val provider = ShortcutsSuggestionProvider(
+ store,
+ context,
+ { selectEngine = it },
+ { selectShortcutEngineSettingsChanged = true },
+ )
+
+ val suggestions = provider.onInputChanged("")
+ assertEquals(2, suggestions.size)
+
+ suggestions[0].onSuggestionClicked?.invoke()
+ suggestions[1].onSuggestionClicked?.invoke()
+
+ assertEquals(engineOne, selectEngine)
+ assertTrue(selectShortcutEngineSettingsChanged)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/toolbar/DefaultSearchSelectorControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/toolbar/DefaultSearchSelectorControllerTest.kt
new file mode 100644
index 0000000000..63f972c3bb
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/toolbar/DefaultSearchSelectorControllerTest.kt
@@ -0,0 +1,71 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.search.toolbar
+
+import androidx.navigation.NavController
+import androidx.navigation.NavDirections
+import androidx.navigation.NavOptions
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.R
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.home.HomeFragmentDirections
+import org.mozilla.fenix.utils.Settings
+
+class DefaultSearchSelectorControllerTest {
+
+ private val activity: HomeActivity = mockk(relaxed = true)
+ private val navController: NavController = mockk(relaxed = true)
+ private val settings: Settings = mockk(relaxed = true)
+
+ private lateinit var controller: DefaultSearchSelectorController
+
+ @Before
+ fun setup() {
+ controller = DefaultSearchSelectorController(
+ activity = activity,
+ navController = navController,
+ )
+
+ every { navController.currentDestination } returns mockk {
+ every { id } returns R.id.homeFragment
+ }
+ every { activity.components.settings } returns settings
+ every { activity.settings() } returns settings
+ }
+
+ @Test
+ fun `WHEN the search settings menu item is tapped THEN navigate to search engine settings fragment`() {
+ controller.handleMenuItemTapped(SearchSelectorMenu.Item.SearchSettings)
+
+ verify {
+ navController.navigate(
+ match<NavDirections> { it.actionId == R.id.action_global_searchEngineFragment },
+ null,
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN a search engine menu item is tapped THEN open the search dialog with the selected search engine`() {
+ val item = mockk<SearchSelectorMenu.Item.SearchEngine>()
+ every { item.searchEngine.id } returns "DuckDuckGo"
+
+ controller.handleMenuItemTapped(item)
+
+ val expectedDirections = HomeFragmentDirections.actionGlobalSearchDialog(
+ sessionId = null,
+ searchEngine = item.searchEngine.id,
+ )
+ verify {
+ navController.navigate(expectedDirections, any<NavOptions>())
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/toolbar/IncreasedTapAreaActionDecoratorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/toolbar/IncreasedTapAreaActionDecoratorTest.kt
new file mode 100644
index 0000000000..e05d4fca5f
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/toolbar/IncreasedTapAreaActionDecoratorTest.kt
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.search.toolbar
+
+import android.view.View
+import io.mockk.MockKAnnotations
+import io.mockk.impl.annotations.MockK
+import io.mockk.justRun
+import io.mockk.mockkStatic
+import io.mockk.verify
+import mozilla.components.concept.toolbar.Toolbar
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.ext.increaseTapArea
+
+class IncreasedTapAreaActionDecoratorTest {
+
+ @MockK lateinit var action: Toolbar.Action
+
+ @MockK lateinit var view: View
+
+ private lateinit var increasedTapAreaActionDecorator: IncreasedTapAreaActionDecorator
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+ increasedTapAreaActionDecorator = IncreasedTapAreaActionDecorator(action)
+ justRun { action.bind(view) }
+ mockkStatic(View::increaseTapArea)
+ justRun { view.increaseTapArea(IncreasedTapAreaActionDecorator.TAP_INCREASE_DPS) }
+ }
+
+ @Test
+ fun `increased tap area of view on bind`() {
+ increasedTapAreaActionDecorator.bind(view)
+
+ verify { view.increaseTapArea(IncreasedTapAreaActionDecorator.TAP_INCREASE_DPS) }
+ }
+
+ @Test
+ fun `should invoke provided action bind`() {
+ increasedTapAreaActionDecorator.bind(view)
+
+ verify { action.bind(view) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/toolbar/SearchSelectorMenuTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/toolbar/SearchSelectorMenuTest.kt
new file mode 100644
index 0000000000..213a57e8f5
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/toolbar/SearchSelectorMenuTest.kt
@@ -0,0 +1,51 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.search.toolbar
+
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.concept.menu.candidate.DecorativeTextMenuCandidate
+import mozilla.components.concept.menu.candidate.TextMenuCandidate
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class SearchSelectorMenuTest {
+
+ private lateinit var menu: SearchSelectorMenu
+ private val interactor = mockk<ToolbarInteractor>()
+
+ @Before
+ fun setup() {
+ menu = SearchSelectorMenu(testContext, interactor)
+ }
+
+ @Test
+ fun `WHEN building the menu items THEN the header is the first item AND the search settings is the last item`() {
+ every { interactor.onMenuItemTapped(any()) } just Runs
+
+ val items = menu.menuItems(listOf())
+ val lastItem = (items.last() as TextMenuCandidate)
+ lastItem.onClick()
+
+ assertEquals(
+ testContext.getString(R.string.search_header_menu_item_2),
+ (items.first() as DecorativeTextMenuCandidate).text,
+ )
+ assertEquals(
+ testContext.getString(R.string.search_settings_menu_item),
+ lastItem.text,
+ )
+ verify { interactor.onMenuItemTapped(SearchSelectorMenu.Item.SearchSettings) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/toolbar/SearchSelectorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/toolbar/SearchSelectorTest.kt
new file mode 100644
index 0000000000..796e7e3fab
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/toolbar/SearchSelectorTest.kt
@@ -0,0 +1,40 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.search.toolbar
+
+import android.view.LayoutInflater
+import androidx.appcompat.content.res.AppCompatResources
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.databinding.SearchSelectorBinding
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class SearchSelectorTest {
+
+ private lateinit var searchSelector: SearchSelector
+ private lateinit var binding: SearchSelectorBinding
+
+ @Before
+ fun setup() {
+ searchSelector = SearchSelector(testContext)
+ binding = SearchSelectorBinding.inflate(LayoutInflater.from(testContext), searchSelector)
+ }
+
+ @Test
+ fun `WHEN set icon is called THEN an icon and its content description are set`() {
+ val icon = AppCompatResources.getDrawable(testContext, R.drawable.ic_search)!!
+ val contentDescription = "contentDescription"
+
+ searchSelector.setIcon(icon, contentDescription)
+
+ assertEquals(icon, binding.icon.drawable)
+ assertEquals(contentDescription, binding.icon.contentDescription)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/toolbar/SearchSelectorToolbarActionTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/toolbar/SearchSelectorToolbarActionTest.kt
new file mode 100644
index 0000000000..db25163c32
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/toolbar/SearchSelectorToolbarActionTest.kt
@@ -0,0 +1,293 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.search.toolbar
+
+import android.graphics.Bitmap
+import android.graphics.Bitmap.Config.ARGB_8888
+import android.graphics.Color
+import android.graphics.drawable.BitmapDrawable
+import android.view.ViewGroup
+import android.widget.LinearLayout
+import androidx.core.graphics.applyCanvas
+import com.google.android.material.card.MaterialCardView
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.browser.state.search.SearchEngine
+import mozilla.components.browser.state.search.SearchEngine.Type.BUNDLED
+import mozilla.components.concept.menu.Orientation
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.UnifiedSearch
+import org.mozilla.fenix.R
+import org.mozilla.fenix.browser.browsingmode.BrowsingMode
+import org.mozilla.fenix.components.metrics.MetricsUtils
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.search.SearchDialogFragmentStore
+import org.mozilla.fenix.search.SearchEngineSource
+import org.mozilla.fenix.search.SearchFragmentAction.SearchDefaultEngineSelected
+import org.mozilla.fenix.search.SearchFragmentAction.SearchHistoryEngineSelected
+import org.mozilla.fenix.search.SearchFragmentState
+import org.mozilla.fenix.utils.Settings
+import java.util.UUID
+
+@RunWith(FenixRobolectricTestRunner::class)
+class SearchSelectorToolbarActionTest {
+
+ private lateinit var store: SearchDialogFragmentStore
+
+ @MockK(relaxed = true)
+ private lateinit var menu: SearchSelectorMenu
+
+ @MockK(relaxed = true)
+ private lateinit var settings: Settings
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ store = SearchDialogFragmentStore(testSearchFragmentState)
+
+ every { testContext.settings() } returns settings
+ }
+
+ @Test
+ fun `WHEN search selector toolbar action is clicked THEN the search selector menu is shown`() {
+ val action = spyk(
+ SearchSelectorToolbarAction(
+ store = store,
+ defaultSearchEngine = null,
+ menu = menu,
+ ),
+ )
+ val view = action.createView(LinearLayout(testContext) as ViewGroup) as SearchSelector
+ val selectorIcon = view.findViewById<MaterialCardView>(R.id.search_selector)
+ assertNull(UnifiedSearch.searchMenuTapped.testGetValue())
+
+ every { settings.shouldUseBottomToolbar } returns false
+ view.performClick()
+
+ assertNotNull(UnifiedSearch.searchMenuTapped.testGetValue())
+ verify {
+ menu.menuController.show(anchor = selectorIcon, Orientation.DOWN)
+ }
+
+ every { settings.shouldUseBottomToolbar } returns true
+ view.performClick()
+
+ assertNotNull(UnifiedSearch.searchMenuTapped.testGetValue())
+ verify {
+ menu.menuController.show(anchor = selectorIcon, Orientation.UP)
+ }
+ }
+
+ @Test
+ fun `GIVEN a binded search selector View WHEN a search engine is selected THEN update the icon`() {
+ mockkStatic("org.mozilla.fenix.search.toolbar.SearchSelectorToolbarActionKt") {
+ val searchEngineIcon: BitmapDrawable = mockk(relaxed = true)
+ every { any<SearchEngine>().getScaledIcon(any()) } returns searchEngineIcon
+ val selector = SearchSelectorToolbarAction(store, mockk(), mockk())
+ val view = spyk(SearchSelector(testContext))
+
+ selector.bind(view)
+ store.dispatch(
+ SearchDefaultEngineSelected(
+ engine = testSearchEngine,
+ browsingMode = BrowsingMode.Normal,
+ settings = mockk(relaxed = true),
+ ),
+ )
+ store.waitUntilIdle()
+
+ verify { testSearchEngine.getScaledIcon(any()) }
+ verify {
+ view.setIcon(
+ icon = searchEngineIcon,
+ contentDescription = testSearchEngine.name,
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `GIVEN the same view is binded multiple times WHEN the search engine changes THEN update the icon only once`() {
+ // This scenario with the same View binded multiple times can happen after a "invalidateActions" call.
+ mockkStatic("org.mozilla.fenix.search.toolbar.SearchSelectorToolbarActionKt") {
+ val searchEngineIcon: BitmapDrawable = mockk(relaxed = true)
+ every { any<SearchEngine>().getScaledIcon(any()) } returns searchEngineIcon
+ val selector = SearchSelectorToolbarAction(store, mockk(), mockk())
+ val view = spyk(SearchSelector(testContext))
+
+ selector.bind(view)
+ selector.bind(view)
+ selector.bind(view)
+ store.dispatch(
+ SearchDefaultEngineSelected(
+ engine = testSearchEngine,
+ browsingMode = BrowsingMode.Private,
+ settings = mockk(relaxed = true),
+ ),
+ )
+ store.waitUntilIdle()
+
+ verify { testSearchEngine.getScaledIcon(any()) }
+ verify(exactly = 1) {
+ view.setIcon(
+ icon = searchEngineIcon,
+ contentDescription = testSearchEngine.name,
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `GIVEN a binded search selector View WHEN a search engine is selected THEN update the icon only if a different search engine is selected`() {
+ mockkStatic("org.mozilla.fenix.search.toolbar.SearchSelectorToolbarActionKt") {
+ val searchEngineIcon: BitmapDrawable = mockk(relaxed = true)
+ every { any<SearchEngine>().getScaledIcon(any()) } returns searchEngineIcon
+ val selector = SearchSelectorToolbarAction(store, mockk(), mockk())
+ val view = spyk(SearchSelector(testContext))
+
+ // Test an initial change
+ selector.bind(view)
+ store.dispatch(
+ SearchDefaultEngineSelected(
+ engine = testSearchEngine,
+ browsingMode = BrowsingMode.Normal,
+ settings = mockk(relaxed = true),
+ ),
+ )
+ store.waitUntilIdle()
+ verify(exactly = 1) { testSearchEngine.getScaledIcon(any()) }
+ verify(exactly = 1) {
+ view.setIcon(
+ icon = searchEngineIcon,
+ contentDescription = testSearchEngine.name,
+ )
+ }
+
+ // Test the same search engine being selected
+ store.dispatch(
+ SearchDefaultEngineSelected(
+ engine = testSearchEngine,
+ browsingMode = BrowsingMode.Private,
+ settings = mockk(relaxed = true),
+ ),
+ )
+ store.waitUntilIdle()
+ verify(exactly = 1) { testSearchEngine.getScaledIcon(any()) }
+ verify(exactly = 1) {
+ view.setIcon(
+ icon = searchEngineIcon,
+ contentDescription = testSearchEngine.name,
+ )
+ }
+
+ // Test another search engine being selected
+ val newSearchEngine = testSearchEngine.copy(
+ name = "NewSearchEngine",
+ )
+ store.dispatch(
+ SearchHistoryEngineSelected(
+ engine = newSearchEngine,
+ ),
+ )
+ store.waitUntilIdle()
+ verify(exactly = 1) { testSearchEngine.getScaledIcon(any()) }
+ verify(exactly = 1) { newSearchEngine.getScaledIcon(any()) }
+ verify(exactly = 1) {
+ view.setIcon(
+ icon = searchEngineIcon,
+ contentDescription = testSearchEngine.name,
+ )
+ }
+ verify(exactly = 1) {
+ view.setIcon(
+ icon = searchEngineIcon,
+ contentDescription = newSearchEngine.name,
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `GIVEN a search engine WHEN asking for a scaled icon THEN return a drawable with a fixed size`() {
+ val originalIcon = Bitmap.createBitmap(100, 100, ARGB_8888).applyCanvas {
+ drawColor(Color.RED)
+ }
+ val expectedScaledIcon = Bitmap.createBitmap(
+ testContext.resources.getDimensionPixelSize(R.dimen.preference_icon_drawable_size),
+ testContext.resources.getDimensionPixelSize(R.dimen.preference_icon_drawable_size),
+ ARGB_8888,
+ ).applyCanvas {
+ drawColor(Color.RED)
+ }
+ val searchEngine = testSearchEngine.copy(
+ icon = originalIcon,
+ )
+
+ val result = searchEngine.getScaledIcon(testContext)
+
+ // Check dimensions, config and pixel data
+ assertTrue(expectedScaledIcon.sameAs(result.bitmap))
+ }
+}
+
+private val testSearchFragmentState = SearchFragmentState(
+ query = "https://example.com",
+ url = "https://example.com",
+ searchTerms = "search terms",
+ searchEngineSource = SearchEngineSource.None,
+ defaultEngine = null,
+ showSearchTermHistory = true,
+ showSearchSuggestions = false,
+ showSearchShortcutsSetting = false,
+ showSearchSuggestionsHint = false,
+ showSearchShortcuts = false,
+ areShortcutsAvailable = false,
+ showClipboardSuggestions = false,
+ showHistorySuggestionsForCurrentEngine = true,
+ showAllHistorySuggestions = false,
+ showBookmarksSuggestionsForCurrentEngine = false,
+ showAllBookmarkSuggestions = false,
+ showSyncedTabsSuggestionsForCurrentEngine = false,
+ showAllSyncedTabsSuggestions = false,
+ showSessionSuggestionsForCurrentEngine = false,
+ showAllSessionSuggestions = true,
+ showSponsoredSuggestions = true,
+ showNonSponsoredSuggestions = true,
+ tabId = "tabId",
+ pastedText = "",
+ searchAccessPoint = MetricsUtils.Source.SHORTCUT,
+)
+
+private val testSearchEngine = SearchEngine(
+ id = UUID.randomUUID().toString(),
+ name = "testSearchEngine",
+ icon = mockk(),
+ type = BUNDLED,
+ resultUrls = listOf(
+ "https://www.startpage.com/sp/search?q={searchTerms}",
+ ),
+)
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/toolbar/ToolbarViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/toolbar/ToolbarViewTest.kt
new file mode 100644
index 0000000000..5aa5baf8d9
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/toolbar/ToolbarViewTest.kt
@@ -0,0 +1,890 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.search.toolbar
+
+import android.content.Context
+import android.graphics.Bitmap
+import androidx.appcompat.view.ContextThemeWrapper
+import androidx.core.graphics.drawable.toBitmap
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.mockkConstructor
+import io.mockk.mockkObject
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.browser.domains.autocomplete.BaseDomainAutocompleteProvider
+import mozilla.components.browser.state.search.SearchEngine
+import mozilla.components.browser.state.state.SearchState
+import mozilla.components.browser.state.state.searchEngines
+import mozilla.components.browser.storage.sync.PlacesBookmarksStorage
+import mozilla.components.browser.storage.sync.PlacesHistoryStorage
+import mozilla.components.browser.toolbar.BrowserToolbar
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.feature.awesomebar.provider.SessionAutocompleteProvider
+import mozilla.components.feature.syncedtabs.SyncedTabsAutocompleteProvider
+import mozilla.components.feature.toolbar.ToolbarAutocompleteFeature
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.ui.autocomplete.InlineAutocompleteEditText
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.FeatureFlags
+import org.mozilla.fenix.R
+import org.mozilla.fenix.components.Components
+import org.mozilla.fenix.components.Core
+import org.mozilla.fenix.components.metrics.MetricsUtils
+import org.mozilla.fenix.ext.requireComponents
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.search.SearchDialogFragment
+import org.mozilla.fenix.search.SearchEngineSource
+import org.mozilla.fenix.search.SearchFragmentState
+import org.mozilla.fenix.utils.Settings
+import java.util.UUID
+
+@RunWith(FenixRobolectricTestRunner::class)
+class ToolbarViewTest {
+ @MockK(relaxed = true)
+ private lateinit var interactor: ToolbarInteractor
+
+ private lateinit var context: Context
+ private lateinit var toolbar: BrowserToolbar
+ private val defaultState: SearchFragmentState = SearchFragmentState(
+ tabId = null,
+ url = "",
+ searchTerms = "",
+ query = "",
+ searchEngineSource = SearchEngineSource.Default(
+ mockk {
+ every { name } returns "Search Engine"
+ every { icon } returns testContext.getDrawable(R.drawable.ic_search)!!.toBitmap()
+ every { type } returns SearchEngine.Type.BUNDLED
+ every { isGeneral } returns true
+ },
+ ),
+ defaultEngine = null,
+ showSearchTermHistory = true,
+ showSearchShortcutsSetting = false,
+ showSearchSuggestionsHint = false,
+ showSearchSuggestions = false,
+ showSearchShortcuts = false,
+ areShortcutsAvailable = true,
+ showClipboardSuggestions = false,
+ showHistorySuggestionsForCurrentEngine = true,
+ showAllHistorySuggestions = false,
+ showBookmarksSuggestionsForCurrentEngine = false,
+ showAllBookmarkSuggestions = false,
+ showSyncedTabsSuggestionsForCurrentEngine = false,
+ showAllSyncedTabsSuggestions = false,
+ showSessionSuggestionsForCurrentEngine = false,
+ showAllSessionSuggestions = false,
+ showSponsoredSuggestions = false,
+ showNonSponsoredSuggestions = false,
+ searchAccessPoint = MetricsUtils.Source.NONE,
+ )
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ context = ContextThemeWrapper(testContext, R.style.NormalTheme)
+ every { context.settings() } returns mockk(relaxed = true)
+ toolbar = spyk(BrowserToolbar(context))
+ }
+
+ @Test
+ fun `sets toolbar to normal mode`() {
+ buildToolbarView(isPrivate = false)
+ assertFalse(toolbar.private)
+ }
+
+ @Test
+ fun `sets toolbar to private mode`() {
+ buildToolbarView(isPrivate = true)
+ assertTrue(toolbar.private)
+ }
+
+ @Test
+ fun `View gets initialized only once`() {
+ val view = buildToolbarView(false)
+ assertFalse(view.isInitialized)
+
+ view.update(defaultState)
+ view.update(defaultState)
+ view.update(defaultState)
+
+ verify(exactly = 1) { toolbar.setSearchTerms(any()) }
+
+ assertTrue(view.isInitialized)
+ }
+
+ @Test
+ fun `search term updates the url`() {
+ val view = buildToolbarView(false)
+
+ view.update(defaultState)
+ view.update(defaultState)
+ view.update(defaultState)
+
+ // editMode gets called when the view is initialized.
+ verify(exactly = 2) { toolbar.editMode() }
+ // search term changes update the url and invoke the interactor.
+ verify(exactly = 2) { toolbar.url = any() }
+ verify(exactly = 2) { interactor.onTextChanged(any()) }
+ }
+
+ @Test
+ fun `GIVEN search term is set WHEN switching to edit mode THEN the cursor is set at the end of the search term`() {
+ every { context.settings().showUnifiedSearchFeature } returns true
+ every { context.settings().shouldShowHistorySuggestions } returns true
+ every { context.settings().shouldShowBookmarkSuggestions } returns true
+ every { context.settings().isTabletAndTabStripEnabled } returns false
+ every { context.settings().enableIncompleteToolbarRedesign } returns false
+ val view = buildToolbarView(false)
+ mockkObject(FeatureFlags)
+
+ view.update(defaultState.copy(searchTerms = "search terms"))
+
+ // editMode gets called when the view is initialized.
+ verify(exactly = 1) { toolbar.editMode(Toolbar.CursorPlacement.ALL) }
+ verify(exactly = 1) { toolbar.editMode(Toolbar.CursorPlacement.END) }
+ }
+
+ @Test
+ fun `GIVEN no search term is set WHEN switching to edit mode THEN the cursor is set at the end of the search term`() {
+ every { context.settings().showUnifiedSearchFeature } returns true
+ every { context.settings().shouldShowHistorySuggestions } returns true
+ every { context.settings().shouldShowBookmarkSuggestions } returns true
+ every { context.settings().isTabletAndTabStripEnabled } returns false
+ every { context.settings().enableIncompleteToolbarRedesign } returns false
+ val view = buildToolbarView(false)
+ mockkObject(FeatureFlags)
+
+ view.update(defaultState)
+
+ // editMode gets called when the view is initialized.
+ verify(exactly = 2) { toolbar.editMode(Toolbar.CursorPlacement.ALL) }
+ }
+
+ @Test
+ fun `URL gets set to the states query`() {
+ val toolbarView = buildToolbarView(false)
+ toolbarView.update(defaultState.copy(query = "Query"))
+
+ assertEquals("Query", toolbarView.view.url)
+ }
+
+ @Test
+ fun `URL gets set to the states pastedText if exists`() {
+ val toolbarView = buildToolbarView(false)
+ toolbarView.update(defaultState.copy(query = "Query", pastedText = "Pasted"))
+
+ assertEquals("Pasted", toolbarView.view.url)
+ }
+
+ @Test
+ fun `searchTerms get set if pastedText is null or empty`() {
+ val toolbarView = buildToolbarView(false)
+ toolbarView.update(defaultState.copy(query = "Query", pastedText = "", searchTerms = "Search Terms"))
+
+ verify { toolbar.setSearchTerms("Search Terms") }
+ }
+
+ @Test
+ fun `searchTerms don't get set if pastedText has a value`() {
+ val toolbarView = buildToolbarView(false)
+ toolbarView.update(
+ defaultState.copy(query = "Query", pastedText = "PastedText", searchTerms = "Search Terms"),
+ )
+
+ verify(exactly = 0) { toolbar.setSearchTerms("Search Terms") }
+ }
+
+ @Test
+ fun `WHEN the default general search engine is selected THEN show text for default engine`() {
+ val toolbarView = buildToolbarView(false)
+ val defaultEngine = buildSearchEngine(SearchEngine.Type.BUNDLED, true)
+ val fragment = spyk(SearchDialogFragment())
+ fragment.inlineAutocompleteEditText = InlineAutocompleteEditText(context)
+ val searchState = mockk<SearchState>()
+ val expectedHint = context.getString(R.string.search_hint)
+ val expectedContentDescription = defaultEngine.name + ", " + context.getString(R.string.search_hint)
+
+ every { fragment.requireContext().getString(R.string.search_hint) } returns expectedHint
+ every { searchState.userSelectedSearchEngineId } returns defaultEngine.id
+ every { searchState.searchEngines } returns listOf(defaultEngine)
+ every { fragment.toolbarView } returns toolbarView
+ every { fragment.requireComponents.core.store.state.search } returns searchState
+
+ fragment.updateToolbarContentDescription(defaultEngine, true)
+
+ assertEquals(expectedHint, fragment.inlineAutocompleteEditText.hint)
+ assertEquals(expectedContentDescription, toolbarView.view.contentDescription)
+ }
+
+ @Test
+ fun `WHEN a general search engine is selected THEN show hint for general engine`() {
+ val toolbarView = buildToolbarView(false)
+ val generalEngine = buildSearchEngine(SearchEngine.Type.BUNDLED, true)
+ val fragment = spyk(SearchDialogFragment())
+ fragment.inlineAutocompleteEditText = InlineAutocompleteEditText(context)
+ val searchState = mockk<SearchState>()
+ val expectedHint = context.getString(R.string.search_hint_general_engine)
+ val expectedContentDescription = generalEngine.name + ", " + context.getString(R.string.search_hint_general_engine)
+
+ every { fragment.requireContext().getString(R.string.search_hint_general_engine) } returns expectedHint
+ every { searchState.userSelectedSearchEngineId } returns generalEngine.id
+ every { searchState.searchEngines } returns listOf(generalEngine)
+ every { fragment.toolbarView } returns toolbarView
+ every { fragment.requireComponents.core.store.state.search } returns searchState
+
+ fragment.updateToolbarContentDescription(generalEngine, false)
+
+ assertEquals(expectedHint, fragment.inlineAutocompleteEditText.hint)
+ assertEquals(expectedContentDescription, toolbarView.view.contentDescription)
+ }
+
+ @Test
+ fun `WHEN a topic specific search engine is selected THEN show hint for topic specific engine`() {
+ val toolbarView = buildToolbarView(false)
+ val topicSpecificEngine = buildSearchEngine(SearchEngine.Type.BUNDLED, false)
+ val fragment = spyk(SearchDialogFragment())
+ fragment.inlineAutocompleteEditText = InlineAutocompleteEditText(context)
+ val searchState = mockk<SearchState>()
+ val expectedHint = context.getString(R.string.application_search_hint)
+ val expectedContentDescription = topicSpecificEngine.name + ", " + context.getString(R.string.application_search_hint)
+
+ every { fragment.requireContext().getString(R.string.application_search_hint) } returns expectedHint
+ every { searchState.userSelectedSearchEngineId } returns topicSpecificEngine.id
+ every { searchState.searchEngines } returns listOf(topicSpecificEngine)
+ every { fragment.toolbarView } returns toolbarView
+ every { fragment.requireComponents.core.store.state.search } returns searchState
+
+ fragment.updateToolbarContentDescription(topicSpecificEngine, false)
+
+ assertEquals(expectedHint, fragment.inlineAutocompleteEditText.hint)
+ assertEquals(expectedContentDescription, toolbarView.view.contentDescription)
+ }
+
+ @Test
+ fun `WHEN the default additional general search engine is selected THEN show hint for default engine`() {
+ val toolbarView = buildToolbarView(false)
+ val defaultEngine = buildSearchEngine(SearchEngine.Type.BUNDLED_ADDITIONAL, true)
+ val fragment = spyk(SearchDialogFragment())
+ fragment.inlineAutocompleteEditText = InlineAutocompleteEditText(context)
+ val searchState = mockk<SearchState>()
+ val expectedHint = context.getString(R.string.search_hint)
+ val expectedContentDescription = defaultEngine.name + ", " + context.getString(R.string.search_hint)
+
+ every { fragment.requireContext().getString(R.string.search_hint) } returns expectedHint
+ every { searchState.userSelectedSearchEngineId } returns defaultEngine.id
+ every { searchState.searchEngines } returns listOf(defaultEngine)
+ every { fragment.toolbarView } returns toolbarView
+ every { fragment.requireComponents.core.store.state.search } returns searchState
+
+ fragment.updateToolbarContentDescription(defaultEngine, true)
+
+ assertEquals(expectedHint, fragment.inlineAutocompleteEditText.hint)
+ assertEquals(expectedContentDescription, toolbarView.view.contentDescription)
+ }
+
+ @Test
+ fun `WHEN a general additional search engine is selected THEN show hint for general engine`() {
+ val toolbarView = buildToolbarView(false)
+ val generalEngine = buildSearchEngine(SearchEngine.Type.BUNDLED_ADDITIONAL, true)
+ val fragment = spyk(SearchDialogFragment())
+ fragment.inlineAutocompleteEditText = InlineAutocompleteEditText(context)
+ val searchState = mockk<SearchState>()
+ val expectedHint = context.getString(R.string.search_hint_general_engine)
+ val expectedContentDescription = generalEngine.name + ", " + context.getString(R.string.search_hint_general_engine)
+
+ every { fragment.requireContext().getString(R.string.search_hint_general_engine) } returns expectedHint
+ every { searchState.userSelectedSearchEngineId } returns generalEngine.id
+ every { searchState.searchEngines } returns listOf(generalEngine)
+ every { fragment.toolbarView } returns toolbarView
+ every { fragment.requireComponents.core.store.state.search } returns searchState
+
+ fragment.updateToolbarContentDescription(generalEngine, false)
+
+ assertEquals(expectedHint, fragment.inlineAutocompleteEditText.hint)
+ assertEquals(expectedContentDescription, toolbarView.view.contentDescription)
+ }
+
+ @Test
+ fun `WHEN the default custom search engine is selected THEN show hint for default engine`() {
+ val toolbarView = buildToolbarView(false)
+ val customEngine = buildSearchEngine(SearchEngine.Type.CUSTOM, true)
+ val fragment = spyk(SearchDialogFragment())
+ fragment.inlineAutocompleteEditText = InlineAutocompleteEditText(context)
+ val searchState = mockk<SearchState>()
+ val expectedHint = context.getString(R.string.search_hint)
+ val expectedContentDescription = customEngine.name + ", " + context.getString(R.string.search_hint)
+
+ every { fragment.requireContext().getString(R.string.search_hint) } returns expectedHint
+ every { searchState.userSelectedSearchEngineId } returns customEngine.id
+ every { searchState.searchEngines } returns listOf(customEngine)
+ every { fragment.toolbarView } returns toolbarView
+ every { fragment.requireComponents.core.store.state.search } returns searchState
+
+ fragment.updateToolbarContentDescription(customEngine, true)
+
+ assertEquals(expectedHint, fragment.inlineAutocompleteEditText.hint)
+ assertEquals(expectedContentDescription, toolbarView.view.contentDescription)
+ }
+
+ @Test
+ fun `WHEN a custom search engine is selected THEN show hint for general engine`() {
+ val toolbarView = buildToolbarView(false)
+ val customEngine = buildSearchEngine(SearchEngine.Type.CUSTOM, true)
+ val fragment = spyk(SearchDialogFragment())
+ fragment.inlineAutocompleteEditText = InlineAutocompleteEditText(context)
+ val searchState = mockk<SearchState>()
+ val expectedHint = context.getString(R.string.search_hint_general_engine)
+ val expectedContentDescription = customEngine.name + ", " + context.getString(R.string.search_hint_general_engine)
+
+ every { fragment.requireContext().getString(R.string.search_hint_general_engine) } returns expectedHint
+ every { searchState.userSelectedSearchEngineId } returns customEngine.id
+ every { searchState.searchEngines } returns listOf(customEngine)
+ every { fragment.toolbarView } returns toolbarView
+ every { fragment.requireComponents.core.store.state.search } returns searchState
+
+ fragment.updateToolbarContentDescription(customEngine, false)
+
+ assertEquals(expectedHint, fragment.inlineAutocompleteEditText.hint)
+ assertEquals(expectedContentDescription, toolbarView.view.contentDescription)
+ }
+
+ @Test
+ fun `WHEN history is selected as engine THEN show hint specific for history`() {
+ val toolbarView = buildToolbarView(false)
+ val historyEngine = buildSearchEngine(SearchEngine.Type.APPLICATION, false, Core.HISTORY_SEARCH_ENGINE_ID)
+ val fragment = spyk(SearchDialogFragment())
+ fragment.inlineAutocompleteEditText = InlineAutocompleteEditText(context)
+ val searchState = mockk<SearchState>()
+ val expectedHint = context.getString(R.string.history_search_hint)
+ val expectedContentDescription = historyEngine.name + ", " + context.getString(R.string.history_search_hint)
+
+ every { fragment.requireContext().getString(R.string.history_search_hint) } returns expectedHint
+ every { searchState.userSelectedSearchEngineId } returns historyEngine.id
+ every { searchState.searchEngines } returns listOf(historyEngine)
+ every { fragment.toolbarView } returns toolbarView
+ every { fragment.requireComponents.core.store.state.search } returns searchState
+
+ fragment.updateToolbarContentDescription(historyEngine, false)
+
+ assertEquals(expectedHint, fragment.inlineAutocompleteEditText.hint)
+ assertEquals(expectedContentDescription, toolbarView.view.contentDescription)
+ }
+
+ @Test
+ fun `WHEN bookmarks is selected as engine THEN show hint specific for bookmarks`() {
+ val toolbarView = buildToolbarView(false)
+ val bookmarksEngine = buildSearchEngine(SearchEngine.Type.APPLICATION, false, Core.BOOKMARKS_SEARCH_ENGINE_ID)
+ val fragment = spyk(SearchDialogFragment())
+ fragment.inlineAutocompleteEditText = InlineAutocompleteEditText(context)
+ val searchState = mockk<SearchState>()
+ val expectedHint = context.getString(R.string.bookmark_search_hint)
+ val expectedContentDescription = bookmarksEngine.name + ", " + context.getString(R.string.bookmark_search_hint)
+
+ every { fragment.requireContext().getString(R.string.bookmark_search_hint) } returns expectedHint
+ every { searchState.userSelectedSearchEngineId } returns bookmarksEngine.id
+ every { searchState.searchEngines } returns listOf(bookmarksEngine)
+ every { fragment.toolbarView } returns toolbarView
+ every { fragment.requireComponents.core.store.state.search } returns searchState
+
+ fragment.updateToolbarContentDescription(bookmarksEngine, false)
+
+ assertEquals(expectedHint, fragment.inlineAutocompleteEditText.hint)
+ assertEquals(expectedContentDescription, toolbarView.view.contentDescription)
+ }
+
+ @Test
+ fun `WHEN tabs is selected as engine THEN show hint specific for tabs`() {
+ val toolbarView = buildToolbarView(false)
+ val tabsEngine = buildSearchEngine(SearchEngine.Type.APPLICATION, false, Core.TABS_SEARCH_ENGINE_ID)
+ val fragment = spyk(SearchDialogFragment())
+ fragment.inlineAutocompleteEditText = InlineAutocompleteEditText(context)
+ val searchState = mockk<SearchState>()
+ val expectedHint = context.getString(R.string.tab_search_hint)
+ val expectedContentDescription = tabsEngine.name + ", " + context.getString(R.string.tab_search_hint)
+
+ every { fragment.requireContext().getString(R.string.tab_search_hint) } returns expectedHint
+ every { searchState.userSelectedSearchEngineId } returns tabsEngine.id
+ every { searchState.searchEngines } returns listOf(tabsEngine)
+ every { fragment.toolbarView } returns toolbarView
+ every { fragment.requireComponents.core.store.state.search } returns searchState
+
+ fragment.updateToolbarContentDescription(tabsEngine, false)
+
+ assertEquals(expectedHint, fragment.inlineAutocompleteEditText.hint)
+ assertEquals(expectedContentDescription, toolbarView.view.contentDescription)
+ }
+
+ @Test
+ fun `GIVEN normal browsing mode WHEN the toolbar view is initialized THEN create an autocomplete feature with valid engine`() {
+ val toolbarView = buildToolbarView(false)
+
+ val autocompleteFeature = toolbarView.autocompleteFeature
+
+ assertNotNull(autocompleteFeature.engine)
+ }
+
+ @Test
+ fun `GIVEN normal private mode WHEN the toolbar view is initialized THEN create an autocomplete feature with null engine`() {
+ val toolbarView = buildToolbarView(true)
+
+ val autocompleteFeature = toolbarView.autocompleteFeature
+
+ assertNull(autocompleteFeature.engine)
+ }
+
+ @Test
+ fun `GIVEN autocomplete disabled WHEN the toolbar view is initialized THEN create an autocomplete with disabled functionality`() {
+ val settings: Settings = mockk {
+ every { shouldAutocompleteInAwesomebar } returns false
+ every { isTabletAndTabStripEnabled } returns false
+ }
+ val toolbarView = buildToolbarView(true, settings)
+
+ val autocompleteFeature = toolbarView.autocompleteFeature
+
+ assertFalse(autocompleteFeature.shouldAutocomplete())
+ }
+
+ @Test
+ fun `GIVEN autocomplete enabled WHEN the toolbar view is initialized THEN create an autocomplete with enabled functionality`() {
+ val settings: Settings = mockk {
+ every { shouldAutocompleteInAwesomebar } returns true
+ every { isTabletAndTabStripEnabled } returns false
+ }
+ val toolbarView = buildToolbarView(true, settings)
+
+ val autocompleteFeature = toolbarView.autocompleteFeature
+
+ assertTrue(autocompleteFeature.shouldAutocomplete())
+ }
+
+ @Test
+ fun `GIVEN unified search is disabled and history suggestions enabled a new search state with the default search engine source selected WHEN updating the toolbar THEN reconfigure autocomplete suggestions`() {
+ mockkConstructor(ToolbarAutocompleteFeature::class) {
+ val historyProvider: PlacesHistoryStorage = mockk(relaxed = true)
+ val domainsProvider: BaseDomainAutocompleteProvider = mockk(relaxed = true)
+ val components: Components = mockk(relaxed = true) {
+ every { core.historyStorage } returns historyProvider
+ every { core.domainsAutocompleteProvider } returns domainsProvider
+ }
+
+ val settings: Settings = mockk(relaxed = true) {
+ every { showUnifiedSearchFeature } returns false
+ every { shouldShowHistorySuggestions } returns true
+ }
+ val toolbarView = buildToolbarView(
+ isPrivate = false,
+ settings = settings,
+ components = components,
+ )
+
+ toolbarView.update(defaultState)
+
+ verify {
+ toolbarView.autocompleteFeature.updateAutocompleteProviders(
+ providers = listOf(historyProvider, domainsProvider),
+ refreshAutocomplete = true,
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `GIVEN unified search is disabled, history suggestions disabled and a new search state with the default search engine source selected WHEN updating the toolbar THEN reconfigure autocomplete suggestions`() {
+ mockkConstructor(ToolbarAutocompleteFeature::class) {
+ val historyProvider: PlacesHistoryStorage = mockk(relaxed = true)
+ val domainsProvider: BaseDomainAutocompleteProvider = mockk(relaxed = true)
+ val components: Components = mockk(relaxed = true) {
+ every { core.historyStorage } returns historyProvider
+ every { core.domainsAutocompleteProvider } returns domainsProvider
+ }
+
+ val settings: Settings = mockk(relaxed = true) {
+ every { showUnifiedSearchFeature } returns false
+ every { shouldShowHistorySuggestions } returns false
+ }
+ val toolbarView = buildToolbarView(
+ isPrivate = false,
+ settings = settings,
+ components = components,
+ )
+
+ toolbarView.update(defaultState)
+
+ verify {
+ toolbarView.autocompleteFeature.updateAutocompleteProviders(
+ providers = listOf(domainsProvider),
+ refreshAutocomplete = true,
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `GIVEN unified search is disabled and a new search state with other than the default search engine source selected WHEN updating the toolbar THEN reconfigure autocomplete suggestions`() {
+ mockkConstructor(ToolbarAutocompleteFeature::class) {
+ val historyProvider: PlacesHistoryStorage = mockk(relaxed = true)
+ val domainsProvider: BaseDomainAutocompleteProvider = mockk(relaxed = true)
+ val components: Components = mockk(relaxed = true) {
+ every { core.historyStorage } returns historyProvider
+ every { core.domainsAutocompleteProvider } returns domainsProvider
+ }
+
+ val settings: Settings = mockk(relaxed = true) {
+ every { showUnifiedSearchFeature } returns false
+ every { shouldShowHistorySuggestions } returns true
+ }
+ val toolbarView = buildToolbarView(
+ isPrivate = false,
+ settings = settings,
+ components = components,
+ )
+
+ toolbarView.update(defaultState.copy(searchEngineSource = SearchEngineSource.Tabs(fakeSearchEngine)))
+ verify {
+ toolbarView.autocompleteFeature.updateAutocompleteProviders(
+ providers = emptyList(),
+ refreshAutocomplete = true,
+ )
+ }
+
+ toolbarView.update(defaultState.copy(searchEngineSource = SearchEngineSource.Bookmarks(fakeSearchEngine)))
+ verify {
+ toolbarView.autocompleteFeature.updateAutocompleteProviders(
+ providers = emptyList(),
+ refreshAutocomplete = true,
+ )
+ }
+
+ toolbarView.update(defaultState.copy(searchEngineSource = SearchEngineSource.History(fakeSearchEngine)))
+ verify {
+ toolbarView.autocompleteFeature.updateAutocompleteProviders(
+ providers = emptyList(),
+ refreshAutocomplete = true,
+ )
+ }
+
+ toolbarView.update(defaultState.copy(searchEngineSource = SearchEngineSource.Shortcut(fakeSearchEngine)))
+ verify {
+ toolbarView.autocompleteFeature.updateAutocompleteProviders(
+ providers = emptyList(),
+ refreshAutocomplete = true,
+ )
+ }
+
+ toolbarView.update(defaultState.copy(searchEngineSource = SearchEngineSource.None))
+ verify {
+ toolbarView.autocompleteFeature.updateAutocompleteProviders(
+ providers = emptyList(),
+ refreshAutocomplete = true,
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `GIVEN history suggestions enabled and a new search state with the default search engine source selected WHEN updating the toolbar THEN reconfigure autocomplete suggestions`() {
+ mockkConstructor(ToolbarAutocompleteFeature::class) {
+ val historyProvider: PlacesHistoryStorage = mockk(relaxed = true)
+ val domainsProvider: BaseDomainAutocompleteProvider = mockk(relaxed = true)
+ val components: Components = mockk(relaxed = true) {
+ every { core.historyStorage } returns historyProvider
+ every { core.domainsAutocompleteProvider } returns domainsProvider
+ }
+
+ val settings: Settings = mockk(relaxed = true) {
+ every { showUnifiedSearchFeature } returns true
+ every { shouldShowHistorySuggestions } returns true
+ }
+ val toolbarView = buildToolbarView(
+ isPrivate = false,
+ settings = settings,
+ components = components,
+ )
+
+ toolbarView.update(defaultState)
+
+ verify {
+ toolbarView.autocompleteFeature.updateAutocompleteProviders(
+ providers = listOf(historyProvider, domainsProvider),
+ refreshAutocomplete = true,
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `GIVEN history suggestions disabled and a new search state with the default search engine source selected WHEN updating the toolbar THEN reconfigure autocomplete suggestions`() {
+ mockkConstructor(ToolbarAutocompleteFeature::class) {
+ val historyProvider: PlacesHistoryStorage = mockk(relaxed = true)
+ val domainsProvider: BaseDomainAutocompleteProvider = mockk(relaxed = true)
+ val components: Components = mockk(relaxed = true) {
+ every { core.historyStorage } returns historyProvider
+ every { core.domainsAutocompleteProvider } returns domainsProvider
+ }
+ val settings: Settings = mockk(relaxed = true) {
+ every { showUnifiedSearchFeature } returns true
+ every { shouldShowHistorySuggestions } returns false
+ }
+ val toolbarView = buildToolbarView(
+ isPrivate = false,
+ settings = settings,
+ components = components,
+ )
+
+ toolbarView.update(defaultState)
+
+ verify {
+ toolbarView.autocompleteFeature.updateAutocompleteProviders(
+ providers = listOf(domainsProvider),
+ refreshAutocomplete = true,
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `GIVEN a new search state with the tabs engine source selected WHEN updating the toolbar THEN reconfigure autocomplete suggestions`() {
+ mockkConstructor(ToolbarAutocompleteFeature::class) {
+ val localSessionProvider: SessionAutocompleteProvider = mockk(relaxed = true)
+ val syncedSessionsProvider: SyncedTabsAutocompleteProvider = mockk(relaxed = true)
+ val components: Components = mockk(relaxed = true) {
+ every { core.sessionAutocompleteProvider } returns localSessionProvider
+ every { backgroundServices.syncedTabsAutocompleteProvider } returns syncedSessionsProvider
+ }
+ val settings: Settings = mockk(relaxed = true) {
+ every { showUnifiedSearchFeature } returns true
+ }
+ val toolbarView = buildToolbarView(
+ isPrivate = false,
+ settings = settings,
+ components = components,
+ )
+
+ toolbarView.update(defaultState.copy(searchEngineSource = SearchEngineSource.Tabs(fakeSearchEngine)))
+
+ verify {
+ toolbarView.autocompleteFeature.updateAutocompleteProviders(
+ providers = listOf(localSessionProvider, syncedSessionsProvider),
+ refreshAutocomplete = true,
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `GIVEN a new search state with the bookmarks engine source selected WHEN updating the toolbar THEN reconfigure autocomplete suggestions`() {
+ mockkConstructor(ToolbarAutocompleteFeature::class) {
+ val bookmarksProvider: PlacesBookmarksStorage = mockk(relaxed = true)
+ val components: Components = mockk(relaxed = true) {
+ every { core.bookmarksStorage } returns bookmarksProvider
+ }
+ val settings: Settings = mockk(relaxed = true) {
+ every { showUnifiedSearchFeature } returns true
+ }
+ val toolbarView = buildToolbarView(
+ isPrivate = false,
+ settings = settings,
+ components = components,
+ )
+
+ toolbarView.update(defaultState.copy(searchEngineSource = SearchEngineSource.Bookmarks(fakeSearchEngine)))
+
+ verify {
+ toolbarView.autocompleteFeature.updateAutocompleteProviders(
+ providers = listOf(bookmarksProvider),
+ refreshAutocomplete = true,
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `GIVEN a new search state with the history engine source selected WHEN updating the toolbar THEN reconfigure autocomplete suggestions`() {
+ mockkConstructor(ToolbarAutocompleteFeature::class) {
+ val historyProvider: PlacesHistoryStorage = mockk(relaxed = true)
+ val components: Components = mockk(relaxed = true) {
+ every { core.historyStorage } returns historyProvider
+ }
+ val settings: Settings = mockk(relaxed = true) {
+ every { showUnifiedSearchFeature } returns true
+ }
+ val toolbarView = buildToolbarView(
+ isPrivate = false,
+ settings = settings,
+ components = components,
+ )
+
+ toolbarView.update(defaultState.copy(searchEngineSource = SearchEngineSource.History(fakeSearchEngine)))
+
+ verify {
+ toolbarView.autocompleteFeature.updateAutocompleteProviders(
+ providers = listOf(historyProvider),
+ refreshAutocomplete = true,
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `GIVEN a new search state with no engine source selected WHEN updating the toolbar THEN reconfigure autocomplete suggestions`() {
+ mockkConstructor(ToolbarAutocompleteFeature::class) {
+ val settings: Settings = mockk(relaxed = true) {
+ every { showUnifiedSearchFeature } returns true
+ }
+ val toolbarView = buildToolbarView(
+ false,
+ settings = settings,
+ )
+
+ toolbarView.update(defaultState.copy(searchEngineSource = SearchEngineSource.None))
+
+ verify {
+ toolbarView.autocompleteFeature.updateAutocompleteProviders(
+ providers = emptyList(),
+ refreshAutocomplete = true,
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `GIVEN a new search state with a shortcut engine source selected WHEN updating the toolbar THEN reconfigure autocomplete suggestions`() {
+ mockkConstructor(ToolbarAutocompleteFeature::class) {
+ val settings: Settings = mockk(relaxed = true) {
+ every { showUnifiedSearchFeature } returns true
+ }
+ val toolbarView = buildToolbarView(
+ isPrivate = false,
+ settings = settings,
+ )
+
+ toolbarView.update(defaultState.copy(searchEngineSource = SearchEngineSource.Shortcut(fakeSearchEngine)))
+
+ verify {
+ toolbarView.autocompleteFeature.updateAutocompleteProviders(
+ providers = emptyList(),
+ refreshAutocomplete = true,
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `GIVEN show bookmark suggestions and unified search are both enabled WHEN the toolbar view is initialized THEN add bookmark storage to autocomplete providers`() {
+ mockkConstructor(ToolbarAutocompleteFeature::class) {
+ val historyProvider: PlacesHistoryStorage = mockk(relaxed = true)
+ val bookmarksStorage: PlacesBookmarksStorage = mockk(relaxed = true)
+ val domainsProvider: BaseDomainAutocompleteProvider = mockk(relaxed = true)
+ val components: Components = mockk(relaxed = true) {
+ every { core.historyStorage } returns historyProvider
+ every { core.domainsAutocompleteProvider } returns domainsProvider
+ every { core.bookmarksStorage } returns bookmarksStorage
+ }
+
+ val settings: Settings = mockk(relaxed = true) {
+ every { showUnifiedSearchFeature } returns true
+ every { shouldShowHistorySuggestions } returns true
+ every { shouldShowBookmarkSuggestions } returns true
+ }
+ val toolbarView = buildToolbarView(
+ isPrivate = false,
+ settings = settings,
+ components = components,
+ )
+
+ toolbarView.update(defaultState)
+
+ verify {
+ toolbarView.autocompleteFeature.updateAutocompleteProviders(
+ providers = listOf(historyProvider, bookmarksStorage, domainsProvider),
+ refreshAutocomplete = true,
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `GIVEN show bookmark suggestions is disabled and unified search is enabled WHEN the toolbar view is initialized THEN don't add bookmark storage to autocomplete providers`() {
+ mockkConstructor(ToolbarAutocompleteFeature::class) {
+ val historyProvider: PlacesHistoryStorage = mockk(relaxed = true)
+ val bookmarksStorage: PlacesBookmarksStorage = mockk(relaxed = true)
+ val domainsProvider: BaseDomainAutocompleteProvider = mockk(relaxed = true)
+ val components: Components = mockk(relaxed = true) {
+ every { core.historyStorage } returns historyProvider
+ every { core.domainsAutocompleteProvider } returns domainsProvider
+ every { core.bookmarksStorage } returns bookmarksStorage
+ }
+
+ val settings: Settings = mockk(relaxed = true) {
+ every { showUnifiedSearchFeature } returns true
+ every { shouldShowHistorySuggestions } returns true
+ every { shouldShowBookmarkSuggestions } returns false
+ }
+ val toolbarView = buildToolbarView(
+ isPrivate = false,
+ settings = settings,
+ components = components,
+ )
+
+ toolbarView.update(defaultState)
+
+ verify {
+ toolbarView.autocompleteFeature.updateAutocompleteProviders(
+ providers = listOf(historyProvider, domainsProvider),
+ refreshAutocomplete = true,
+ )
+ }
+ }
+ }
+
+ private fun buildToolbarView(
+ isPrivate: Boolean,
+ settings: Settings = context.settings(),
+ components: Components = mockk(relaxed = true),
+ ) = ToolbarView(
+ settings = settings,
+ components = components,
+ interactor = interactor,
+ isPrivate = isPrivate,
+ view = toolbar,
+ fromHomeFragment = false,
+ )
+
+ private fun buildSearchEngine(
+ type: SearchEngine.Type,
+ isGeneral: Boolean,
+ id: String = UUID.randomUUID().toString(),
+ ) = SearchEngine(
+ id = id,
+ name = UUID.randomUUID().toString(),
+ icon = testContext.getDrawable(R.drawable.ic_search)!!.toBitmap(),
+ type = type,
+ isGeneral = isGeneral,
+ )
+}
+
+/**
+ * Get a fake [SearchEngine] to use where a simple mock won't suffice.
+ */
+private val fakeSearchEngine = SearchEngine(
+ id = "fakeId",
+ name = "fakeName",
+ icon = Bitmap.createBitmap(1, 1, Bitmap.Config.ALPHA_8),
+ type = SearchEngine.Type.CUSTOM,
+ resultUrls = emptyList(),
+)
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/session/PrivateNotificationServiceTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/session/PrivateNotificationServiceTest.kt
new file mode 100644
index 0000000000..37f339b2d7
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/session/PrivateNotificationServiceTest.kt
@@ -0,0 +1,80 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.session
+
+import android.content.ComponentName
+import android.content.Intent
+import io.mockk.every
+import io.mockk.mockk
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.feature.privatemode.notification.AbstractPrivateNotificationService.Companion.ACTION_ERASE
+import mozilla.components.feature.tabs.TabsUseCases
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.HomeActivity.Companion.PRIVATE_BROWSING_MODE
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.robolectric.Robolectric
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.android.controller.ServiceController
+
+@RunWith(FenixRobolectricTestRunner::class)
+class PrivateNotificationServiceTest {
+
+ private lateinit var controller: ServiceController<PrivateNotificationService>
+ private lateinit var store: BrowserStore
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Before
+ fun setup() {
+ store = mockk()
+ every { store.dispatch(any()) } returns mockk()
+ every { testContext.components.core.store } returns store
+ every { testContext.components.useCases.tabsUseCases } returns TabsUseCases(store)
+
+ controller = Robolectric.buildService(
+ PrivateNotificationService::class.java,
+ Intent(ACTION_ERASE),
+ )
+ }
+
+ @Test
+ fun `service opens home activity in private mode if app is in private mode`() {
+ val selectedPrivateTab = createTab("https://mozilla.org", private = true)
+ every { store.state } returns BrowserState(tabs = listOf(selectedPrivateTab), selectedTabId = selectedPrivateTab.id)
+
+ val service = shadowOf(controller.get())
+ controller.startCommand(0, 0)
+
+ val intent = service.nextStartedActivity
+ assertNotNull(intent)
+ assertEquals(ComponentName(testContext, HomeActivity::class.java), intent.component)
+ assertEquals(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK, intent.flags)
+ assertEquals(true, intent.extras?.getBoolean(PRIVATE_BROWSING_MODE))
+ }
+
+ @Test
+ fun `service starts no activity if app is in normal mode`() {
+ val selectedPrivateTab = createTab("https://mozilla.org", private = false)
+ every { store.state } returns BrowserState(tabs = listOf(selectedPrivateTab), selectedTabId = selectedPrivateTab.id)
+
+ val service = shadowOf(controller.get())
+ controller.startCommand(0, 0)
+
+ assertNull(service.nextStartedActivity)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/CustomEtpCookiesOptionsDropDownListPreferenceTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/CustomEtpCookiesOptionsDropDownListPreferenceTest.kt
new file mode 100644
index 0000000000..4f3245ad01
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/CustomEtpCookiesOptionsDropDownListPreferenceTest.kt
@@ -0,0 +1,86 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings
+
+import android.content.Context
+import androidx.preference.Preference
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class CustomEtpCookiesOptionsDropDownListPreferenceTest {
+ @Test
+ fun `GIVEN total cookie protection is enabled WHEN using this preference THEN show the total cookie protection option`() {
+ val expectedEntries = arrayOf(
+ testContext.getString(R.string.preference_enhanced_tracking_protection_custom_cookies_5),
+ ) + defaultEntries
+ val expectedValues = arrayOf(testContext.getString(R.string.total_protection)) + defaultValues
+
+ mockkStatic("org.mozilla.fenix.ext.ContextKt") {
+ every { any<Context>().settings() } returns mockk {
+ every { enabledTotalCookieProtection } returns true
+ }
+
+ val preference = CustomEtpCookiesOptionsDropDownListPreference(testContext)
+
+ assertArrayEquals(expectedEntries, preference.entries)
+ assertArrayEquals(expectedValues, preference.entryValues)
+ assertEquals(expectedValues[0], preference.getDefaultValue())
+ }
+ }
+
+ @Test
+ fun `GIVEN total cookie protection is disabled WHEN using this preference THEN don't show the total cookie protection option`() {
+ mockkStatic("org.mozilla.fenix.ext.ContextKt") {
+ every { any<Context>().settings() } returns mockk {
+ every { enabledTotalCookieProtection } returns false
+ }
+
+ val preference = CustomEtpCookiesOptionsDropDownListPreference(testContext)
+
+ assertArrayEquals(defaultEntries, preference.entries)
+ assertArrayEquals(defaultValues, preference.entryValues)
+ assertEquals(defaultValues[0], preference.getDefaultValue())
+ }
+ }
+
+ /**
+ * Use reflection to get the private member holding the default value set for this preference.
+ */
+ private fun CustomEtpCookiesOptionsDropDownListPreference.getDefaultValue(): String {
+ return Preference::class.java
+ .getDeclaredField("mDefaultValue").let { field ->
+ field.isAccessible = true
+ return@let field.get(this) as String
+ }
+ }
+
+ private val defaultEntries = with(testContext) {
+ arrayOf(
+ getString(R.string.preference_enhanced_tracking_protection_custom_cookies_1),
+ getString(R.string.preference_enhanced_tracking_protection_custom_cookies_2),
+ getString(R.string.preference_enhanced_tracking_protection_custom_cookies_3),
+ getString(R.string.preference_enhanced_tracking_protection_custom_cookies_4),
+ )
+ }
+
+ private val defaultValues = with(testContext) {
+ arrayOf(
+ getString(R.string.social),
+ getString(R.string.unvisited),
+ getString(R.string.third_party),
+ getString(R.string.all),
+ )
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/DropDownListPreferenceTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/DropDownListPreferenceTest.kt
new file mode 100644
index 0000000000..44892cd370
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/DropDownListPreferenceTest.kt
@@ -0,0 +1,52 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.fenix.settings
+
+import androidx.preference.ListPreference
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+
+class DropDownListPreferenceTest {
+
+ private lateinit var preference: ListPreference
+
+ @Before
+ fun before() {
+ preference = mockk()
+ }
+
+ @Test
+ fun `WHEN findEntriesValue is called with a non-string THEN it returns null`() {
+ assertNull(preference.findEntry(null))
+ assertNull(preference.findEntry(1))
+ assertNull(preference.findEntry(Object()))
+ assertNull(preference.findEntry(listOf<Char>()))
+ }
+
+ @Test
+ fun `GIVEN newValue is not found in entryValues WHEN findEntriesValue is called with newValue THEN it should return null`() {
+ val newValue = "key"
+ every { preference.entries } returns arrayOf()
+ every { preference.entryValues } returns arrayOf()
+
+ assertNull(preference.findEntry(newValue))
+ }
+
+ @Test
+ fun `GIVEN entryValues and entries contain values WHEN findEntriesValue is called THEN it should return the entry`() {
+ val entries = arrayOf("use private mode!", "use normal mode!", "use something else!")
+ val entryValues = arrayOf("private", "normal", "other")
+
+ every { preference.entries } returns entries
+ every { preference.entryValues } returns entryValues
+
+ assertEquals(entries[0], preference.findEntry(entryValues[0]))
+ assertEquals(entries[1], preference.findEntry(entryValues[1]))
+ assertEquals(entries[2], preference.findEntry(entryValues[2]))
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/ExtensionsTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/ExtensionsTest.kt
new file mode 100644
index 0000000000..b7c0d910fc
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/ExtensionsTest.kt
@@ -0,0 +1,109 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings
+
+import android.graphics.Rect
+import android.widget.RadioButton
+import androidx.preference.Preference
+import androidx.preference.PreferenceFragmentCompat
+import androidx.preference.SwitchPreference
+import io.mockk.Called
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.support.ktx.android.view.putCompoundDrawablesRelative
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class ExtensionsTest {
+
+ @MockK(relaxUnitFun = true)
+ private lateinit var radioButton: RadioButton
+
+ @MockK private lateinit var fragment: PreferenceFragmentCompat
+ private lateinit var preference: Preference
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ preference = Preference(testContext)
+
+ every { radioButton.context } returns testContext
+ every {
+ fragment.getString(R.string.pref_key_accessibility_force_enable_zoom)
+ } returns "pref_key_accessibility_force_enable_zoom"
+ every {
+ fragment.getString(R.string.pref_key_accessibility_auto_size)
+ } returns "pref_key_accessibility_auto_size"
+ }
+
+ @Test
+ fun `test radiobutton setStartCheckedIndicator`() {
+ radioButton.setStartCheckedIndicator()
+
+ verify {
+ radioButton.putCompoundDrawablesRelative(
+ start = withArg {
+ assertEquals(Rect(0, 0, it.intrinsicWidth, it.intrinsicHeight), it.bounds)
+ },
+ )
+ }
+ }
+
+ @Test
+ fun `set change listener with typed argument`() {
+ val callback = mockk<(Preference, String) -> Unit>(relaxed = true)
+ preference.setOnPreferenceChangeListener<String> { pref, value ->
+ callback(pref, value)
+ true
+ }
+
+ assertFalse(preference.callChangeListener(10))
+ verify { callback wasNot Called }
+
+ assertTrue(preference.callChangeListener("Hello"))
+ verify { callback(preference, "Hello") }
+ }
+
+ @Test
+ fun `requirePreference returns corresponding preference`() {
+ val switchPreference = mockk<SwitchPreference>()
+ every {
+ fragment.findPreference<SwitchPreference>("pref_key_accessibility_auto_size")
+ } returns switchPreference
+
+ assertEquals(
+ switchPreference,
+ fragment.requirePreference<SwitchPreference>(R.string.pref_key_accessibility_auto_size),
+ )
+ }
+
+ @Test
+ fun `requirePreference throws if null preference is returned`() {
+ every {
+ fragment.findPreference<SwitchPreference>("pref_key_accessibility_force_enable_zoom")
+ } returns null
+
+ var exception: IllegalArgumentException? = null
+ try {
+ fragment.requirePreference<SwitchPreference>(R.string.pref_key_accessibility_force_enable_zoom)
+ } catch (e: IllegalArgumentException) {
+ exception = e
+ }
+
+ assertNotNull(exception)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/HomeSettingsFragmentTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/HomeSettingsFragmentTest.kt
new file mode 100644
index 0000000000..b569993d9e
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/HomeSettingsFragmentTest.kt
@@ -0,0 +1,147 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.fragment.app.FragmentActivity
+import androidx.preference.CheckBoxPreference
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.mockkStatic
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import mozilla.components.service.pocket.PocketStoriesService
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.After
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.FeatureFlags
+import org.mozilla.fenix.R
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppAction
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.getPreferenceKey
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.utils.Settings
+import org.robolectric.Robolectric
+
+@RunWith(FenixRobolectricTestRunner::class)
+internal class HomeSettingsFragmentTest {
+ private lateinit var homeSettingsFragment: HomeSettingsFragment
+ private lateinit var appSettings: Settings
+ private lateinit var appPrefs: SharedPreferences
+ private lateinit var appPrefsEditor: SharedPreferences.Editor
+ private lateinit var pocketService: PocketStoriesService
+ private lateinit var appStore: AppStore
+
+ @Before
+ fun setup() {
+ mockkStatic("org.mozilla.fenix.ext.FragmentKt")
+ mockkStatic("org.mozilla.fenix.ext.ContextKt")
+ appPrefsEditor = mockk(relaxed = true)
+ appPrefs = mockk(relaxed = true) {
+ every { edit() } returns appPrefsEditor
+ }
+ appSettings = mockk(relaxed = true) {
+ every { preferences } returns appPrefs
+ }
+ every { any<Context>().settings() } returns appSettings
+ appStore = mockk(relaxed = true)
+ pocketService = mockk(relaxed = true)
+ every { any<Context>().components } returns mockk {
+ every { appStore } returns this@HomeSettingsFragmentTest.appStore
+ every { core.pocketStoriesService } returns pocketService
+ }
+
+ homeSettingsFragment = HomeSettingsFragment()
+ }
+
+ @After
+ fun teardown() {
+ unmockkStatic("org.mozilla.fenix.ext.ContextKt")
+ unmockkStatic("org.mozilla.fenix.ext.FragmentKt")
+ }
+
+ @Test
+ fun `GIVEN the Pocket sponsored stories feature is disabled for the app WHEN accessing settings THEN the settings for it are not visible`() {
+ mockkObject(FeatureFlags) {
+ every { FeatureFlags.isPocketSponsoredStoriesFeatureEnabled(any()) } returns false
+
+ activateFragment()
+
+ assertFalse(getSponsoredStoriesPreference().isVisible)
+ }
+ }
+
+ @Test
+ fun `GIVEN the Pocket sponsored stories feature is enabled for the app WHEN accessing settings THEN the settings for it are visible`() {
+ mockkObject(FeatureFlags) {
+ every { FeatureFlags.isPocketSponsoredStoriesFeatureEnabled(any()) } returns true
+
+ activateFragment()
+
+ assertTrue(getSponsoredStoriesPreference().isVisible)
+ }
+ }
+
+ @Test
+ fun `GIVEN the Pocket sponsored stories preference is false WHEN accessing settings THEN the setting for it is unchecked`() {
+ every { appSettings.showPocketSponsoredStories } returns false
+
+ activateFragment()
+
+ assertFalse(getSponsoredStoriesPreference().isChecked)
+ }
+
+ @Test
+ fun `GIVEN the Pocket sponsored stories preference is true WHEN accessing settings THEN the setting for it is checked`() {
+ every { appSettings.showPocketSponsoredStories } returns true
+
+ activateFragment()
+
+ assertTrue(getSponsoredStoriesPreference().isChecked)
+ }
+
+ @Test
+ fun `GIVEN the setting for Pocket sponsored stories is unchecked WHEN tapping it THEN toggle it and start downloading stories`() {
+ activateFragment()
+
+ val result = getSponsoredStoriesPreference().callChangeListener(true)
+
+ assertTrue(result)
+ verify { appPrefsEditor.putBoolean(testContext.getString(R.string.pref_key_pocket_sponsored_stories), true) }
+ verify { pocketService.startPeriodicSponsoredStoriesRefresh() }
+ }
+
+ @Test
+ fun `GIVEN the setting for Pocket sponsored stories is checked WHEN tapping it THEN toggle it, delete Pocket profile and remove sponsored stories from showing`() {
+ activateFragment()
+
+ val result = getSponsoredStoriesPreference().callChangeListener(false)
+
+ assertTrue(result)
+ verify { appPrefsEditor.putBoolean(testContext.getString(R.string.pref_key_pocket_sponsored_stories), false) }
+ verify { pocketService.deleteProfile() }
+ verify { appStore.dispatch(AppAction.PocketSponsoredStoriesChange(emptyList())) }
+ }
+
+ private fun activateFragment() {
+ val activity = Robolectric.buildActivity(FragmentActivity::class.java).create().get()
+ activity.supportFragmentManager.beginTransaction()
+ .add(homeSettingsFragment, "HomeSettingFragmentTest")
+ .commitNow()
+ }
+
+ private fun getSponsoredStoriesPreference(): CheckBoxPreference =
+ homeSettingsFragment.findPreference(
+ homeSettingsFragment.getPreferenceKey(R.string.pref_key_pocket_sponsored_stories),
+ )!!
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/OnSharedPreferenceChangeListenerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/OnSharedPreferenceChangeListenerTest.kt
new file mode 100644
index 0000000000..2a52e1edd7
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/OnSharedPreferenceChangeListenerTest.kt
@@ -0,0 +1,57 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings
+
+import android.content.SharedPreferences
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import io.mockk.Called
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class OnSharedPreferenceChangeListenerTest {
+
+ private lateinit var sharedPrefs: SharedPreferences
+ private lateinit var listener: (SharedPreferences, String?) -> Unit
+ private lateinit var owner: LifecycleOwner
+ private lateinit var lifecycleRegistry: LifecycleRegistry
+
+ @Before
+ fun setup() {
+ sharedPrefs = mockk(relaxUnitFun = true)
+ listener = mockk(relaxed = true)
+ owner = object : LifecycleOwner {
+ override val lifecycle: Lifecycle
+ get() = lifecycleRegistry
+ }
+ lifecycleRegistry = LifecycleRegistry(owner)
+ }
+
+ @Test
+ fun `test listener is registered based on lifecycle`() {
+ sharedPrefs.registerOnSharedPreferenceChangeListener(owner, listener)
+ verify { sharedPrefs wasNot Called }
+
+ lifecycleRegistry.currentState = Lifecycle.State.CREATED
+ verify { sharedPrefs.registerOnSharedPreferenceChangeListener(any()) }
+
+ lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
+ verify { sharedPrefs.unregisterOnSharedPreferenceChangeListener(any()) }
+ }
+
+ @Test
+ fun `listener should call lambda`() {
+ val wrapper = OnSharedPreferenceChangeListener(mockk(), listener)
+ wrapper.onSharedPreferenceChanged(sharedPrefs, "key")
+
+ verify { listener(sharedPrefs, "key") }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/PhoneFeatureTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/PhoneFeatureTest.kt
new file mode 100644
index 0000000000..2b1588da3b
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/PhoneFeatureTest.kt
@@ -0,0 +1,96 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings
+
+import android.Manifest
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import mozilla.components.concept.engine.permission.SitePermissions
+import mozilla.components.concept.engine.permission.SitePermissions.Status
+import mozilla.components.feature.sitepermissions.SitePermissionsRules.Action
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class PhoneFeatureTest {
+
+ @MockK private lateinit var sitePermissions: SitePermissions
+
+ @MockK private lateinit var settings: Settings
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ }
+
+ @Test
+ fun `getStatus throws if both values are null`() {
+ var exception: IllegalArgumentException? = null
+ try {
+ PhoneFeature.AUTOPLAY_AUDIBLE.getStatus()
+ } catch (e: java.lang.IllegalArgumentException) {
+ exception = e
+ }
+ assertNotNull(exception)
+ }
+
+ @Test
+ fun `getStatus returns value from site permissions`() {
+ every { sitePermissions.notification } returns Status.BLOCKED
+ assertEquals(Status.BLOCKED, PhoneFeature.NOTIFICATION.getStatus(sitePermissions, settings))
+ }
+
+ @Test
+ fun `getStatus returns value from settings`() {
+ every {
+ settings.getSitePermissionsPhoneFeatureAction(PhoneFeature.AUTOPLAY_INAUDIBLE, Action.ALLOWED)
+ } returns Action.ALLOWED
+ assertEquals(Status.ALLOWED, PhoneFeature.AUTOPLAY_INAUDIBLE.getStatus(settings = settings))
+ }
+
+ @Test
+ fun getLabel() {
+ assertEquals("Camera", PhoneFeature.CAMERA.getLabel(testContext))
+ assertEquals("Location", PhoneFeature.LOCATION.getLabel(testContext))
+ assertEquals("Microphone", PhoneFeature.MICROPHONE.getLabel(testContext))
+ assertEquals("Notification", PhoneFeature.NOTIFICATION.getLabel(testContext))
+ assertEquals("Autoplay", PhoneFeature.AUTOPLAY_AUDIBLE.getLabel(testContext))
+ assertEquals("Autoplay", PhoneFeature.AUTOPLAY_INAUDIBLE.getLabel(testContext))
+ assertEquals("Autoplay", PhoneFeature.AUTOPLAY.getLabel(testContext))
+ }
+
+ @Test
+ fun getPreferenceId() {
+ assertEquals(R.string.pref_key_phone_feature_camera, PhoneFeature.CAMERA.getPreferenceId())
+ assertEquals(R.string.pref_key_phone_feature_location, PhoneFeature.LOCATION.getPreferenceId())
+ assertEquals(R.string.pref_key_phone_feature_microphone, PhoneFeature.MICROPHONE.getPreferenceId())
+ assertEquals(R.string.pref_key_phone_feature_notification, PhoneFeature.NOTIFICATION.getPreferenceId())
+ assertEquals(R.string.pref_key_browser_feature_autoplay_audible_v2, PhoneFeature.AUTOPLAY_AUDIBLE.getPreferenceId())
+ assertEquals(R.string.pref_key_browser_feature_autoplay_inaudible_v2, PhoneFeature.AUTOPLAY_INAUDIBLE.getPreferenceId())
+ assertEquals(R.string.pref_key_browser_feature_autoplay_v2, PhoneFeature.AUTOPLAY.getPreferenceId())
+ }
+
+ @Test
+ fun `getAction returns value from settings`() {
+ every {
+ settings.getSitePermissionsPhoneFeatureAction(PhoneFeature.AUTOPLAY_AUDIBLE, Action.BLOCKED)
+ } returns Action.ASK_TO_ALLOW
+ assertEquals(Action.ASK_TO_ALLOW, PhoneFeature.AUTOPLAY_AUDIBLE.getAction(settings))
+ }
+
+ @Test
+ fun findFeatureBy() {
+ assertEquals(PhoneFeature.CAMERA, PhoneFeature.findFeatureBy(arrayOf(Manifest.permission.CAMERA)))
+ assertEquals(PhoneFeature.MICROPHONE, PhoneFeature.findFeatureBy(arrayOf(Manifest.permission.RECORD_AUDIO)))
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/PreferenceBackedRadioButtonTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/PreferenceBackedRadioButtonTest.kt
new file mode 100644
index 0000000000..734db75ec9
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/PreferenceBackedRadioButtonTest.kt
@@ -0,0 +1,147 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings
+
+import android.content.Context
+import android.content.SharedPreferences.Editor
+import android.widget.CompoundButton.OnCheckedChangeListener
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.spyk
+import io.mockk.verify
+import io.mockk.verifyOrder
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.utils.Settings
+import org.robolectric.Robolectric
+import kotlin.random.Random
+
+@RunWith(FenixRobolectricTestRunner::class)
+class PreferenceBackedRadioButtonTest {
+ @Test
+ fun `GIVEN a preference key is provided WHEN initialized THEN cache the value`() {
+ val attributes = Robolectric.buildAttributeSet()
+ .addAttribute(R.attr.preferenceKey, "test")
+ .build()
+ every { testContext.settings().preferences.getBoolean(any(), any()) } returns Random.nextBoolean()
+
+ val button = PreferenceBackedRadioButton(testContext, attributes)
+
+ assertEquals("test", button.backingPreferenceName)
+ }
+
+ @Test
+ fun `GIVEN a default preference value is provided WHEN initialized THEN cache the value`() {
+ val attributes = Robolectric.buildAttributeSet()
+ .addAttribute(R.attr.preferenceKeyDefaultValue, "true")
+ .build()
+ every { testContext.settings().preferences.getBoolean(any(), any()) } returns Random.nextBoolean()
+
+ val button = PreferenceBackedRadioButton(testContext, attributes)
+
+ assertTrue(button.backingPreferenceDefaultValue)
+ }
+
+ @Test
+ fun `GIVEN a default preference value is not provided WHEN initialized THEN remember the default value as false`() {
+ val attributes = Robolectric.buildAttributeSet().build()
+ every { testContext.settings().preferences.getBoolean(any(), any()) } returns Random.nextBoolean()
+
+ val button = PreferenceBackedRadioButton(testContext, attributes)
+
+ assertFalse(button.backingPreferenceDefaultValue)
+ }
+
+ @Test
+ fun `GIVEN the backing preference doesn't have a value set WHEN initialized THEN set if checked the default value`() {
+ val attributes = Robolectric.buildAttributeSet()
+ .addAttribute(R.attr.preferenceKeyDefaultValue, "true")
+ .build()
+
+ mockkStatic("org.mozilla.fenix.ext.ContextKt") {
+ every { any<Context>().settings() } returns Settings(testContext)
+
+ val button = PreferenceBackedRadioButton(testContext, attributes)
+
+ assertTrue(button.isChecked)
+ }
+ }
+
+ @Test
+ fun `GIVEN there is no backing preference or default value set vaWHEN initialized THEN set if checked as false`() {
+ val attributes = Robolectric.buildAttributeSet().build()
+
+ mockkStatic("org.mozilla.fenix.ext.ContextKt") {
+ every { any<Context>().settings() } returns Settings(testContext)
+
+ val button = PreferenceBackedRadioButton(testContext, attributes)
+
+ assertFalse(button.isChecked)
+ }
+ }
+
+ @Test
+ fun `GIVEN the backing preference does have a value set WHEN initialized THEN set if checked the value from the preference`() {
+ val attributes = Robolectric.buildAttributeSet()
+ .addAttribute(R.attr.preferenceKey, "test")
+ .build()
+ every { testContext.settings().preferences.getBoolean(eq("test"), any()) } returns true
+
+ val button = PreferenceBackedRadioButton(testContext, attributes)
+
+ assertTrue(button.isChecked)
+ }
+
+ @Test
+ fun `WHEN a OnCheckedChangeListener is set THEN cache it internally`() {
+ every { testContext.settings().preferences.getBoolean(any(), any()) } returns Random.nextBoolean()
+ val button = PreferenceBackedRadioButton(testContext)
+ val testListener: OnCheckedChangeListener = mockk()
+
+ button.setOnCheckedChangeListener(testListener)
+
+ assertSame(testListener, button.externalOnCheckedChangeListener)
+ }
+
+ @Test
+ fun `GIVEN a OnCheckedChangeListener is set WHEN the checked status changes THEN update the backing preference and then inform the listener`() {
+ val editor: Editor = mockk(relaxed = true)
+ every { testContext.settings().preferences.edit() } returns editor
+ // set the button initially as not checked
+ every { testContext.settings().preferences.getBoolean(any(), any()) } returns false
+ val button = PreferenceBackedRadioButton(testContext)
+ button.backingPreferenceName = "test"
+ val testListener: OnCheckedChangeListener = mockk(relaxed = true)
+ button.externalOnCheckedChangeListener = testListener
+
+ button.isChecked = true
+
+ verifyOrder {
+ editor.putBoolean("test", true)
+ testListener.onCheckedChanged(any(), any())
+ }
+ }
+
+ @Test
+ fun `WHEN the button gets enabled THEN set isChecked based on the value from the backing preference`() {
+ every { testContext.settings().preferences.getBoolean(any(), any()) } returns true
+ val button = spyk(PreferenceBackedRadioButton(testContext))
+
+ button.isEnabled = true
+
+ verify(exactly = 1) { // first "isChecked" from init happens before we can count it
+ button.isChecked = true
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/SettingsFragmentTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/SettingsFragmentTest.kt
new file mode 100644
index 0000000000..9e8ad2f52a
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/SettingsFragmentTest.kt
@@ -0,0 +1,447 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings
+
+import android.os.Build
+import androidx.core.app.NotificationManagerCompat
+import androidx.fragment.app.FragmentActivity
+import androidx.preference.Preference
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.mockkStatic
+import io.mockk.unmockkObject
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import kotlinx.coroutines.test.advanceUntilIdle
+import mozilla.components.browser.state.state.SearchState
+import mozilla.components.browser.state.state.selectedOrDefaultSearchEngine
+import mozilla.components.concept.fetch.Client
+import mozilla.components.service.fxa.manager.FxaAccountManager
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.Config
+import org.mozilla.fenix.FeatureFlags
+import org.mozilla.fenix.R
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.getPreferenceKey
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.utils.Settings
+import org.robolectric.Robolectric
+import java.io.IOException
+
+@RunWith(FenixRobolectricTestRunner::class)
+class SettingsFragmentTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val settingsFragment = SettingsFragment()
+
+ @Before
+ fun setup() {
+ // Mock client for fetching account avatar
+ val client = mockk<Client>()
+ every { client.fetch(any()) } throws IOException("test")
+
+ every { testContext.components.core.engine.profiler } returns mockk(relaxed = true)
+ every { testContext.components.core.client } returns client
+ every { testContext.components.settings } returns mockk(relaxed = true)
+ every { testContext.components.addonManager } returns mockk(relaxed = true)
+ every { testContext.components.analytics } returns mockk(relaxed = true)
+ every { testContext.components.backgroundServices } returns mockk(relaxed = true)
+
+ mockkObject(FeatureFlags)
+
+ val activity = Robolectric.buildActivity(FragmentActivity::class.java).create().get()
+ activity.supportFragmentManager.beginTransaction()
+ .add(settingsFragment, "test")
+ .commitNow()
+ }
+
+ @Test
+ fun `Add-on collection override pref is visible if debug menu active and feature is enabled`() = runTestOnMain {
+ val settingsFragment = SettingsFragment()
+ val activity = Robolectric.buildActivity(FragmentActivity::class.java).create().get()
+
+ activity.supportFragmentManager.beginTransaction()
+ .add(settingsFragment, "test")
+ .commitNow()
+
+ advanceUntilIdle()
+
+ every { FeatureFlags.customExtensionCollectionFeature } returns true
+
+ val preferenceAmoCollectionOverride = settingsFragment.findPreference<Preference>(
+ settingsFragment.getPreferenceKey(R.string.pref_key_override_amo_collection),
+ )
+
+ settingsFragment.setupAmoCollectionOverridePreference(mockk(relaxed = true))
+ assertNotNull(preferenceAmoCollectionOverride)
+ assertFalse(preferenceAmoCollectionOverride!!.isVisible)
+
+ val settings: Settings = mockk(relaxed = true)
+ every { settings.showSecretDebugMenuThisSession } returns true
+ settingsFragment.setupAmoCollectionOverridePreference(settings)
+ assertTrue(preferenceAmoCollectionOverride.isVisible)
+ }
+
+ @Test
+ @org.robolectric.annotation.Config(sdk = [Build.VERSION_CODES.Q])
+ fun `Install add-on from file pref is visible if debug menu active and feature is enabled`() = runTestOnMain {
+ val settingsFragment = SettingsFragment()
+ val activity = Robolectric.buildActivity(FragmentActivity::class.java).create().get()
+
+ activity.supportFragmentManager.beginTransaction()
+ .add(settingsFragment, "test")
+ .commitNow()
+
+ advanceUntilIdle()
+
+ val preference = settingsFragment.findPreference<Preference>(
+ settingsFragment.getPreferenceKey(R.string.pref_key_install_local_addon),
+ )
+
+ settingsFragment.setupInstallAddonFromFilePreference(mockk(relaxed = true))
+ assertNotNull(preference)
+ assertFalse(preference!!.isVisible)
+
+ val settings: Settings = mockk(relaxed = true)
+
+ every { settings.showSecretDebugMenuThisSession } returns true
+ settingsFragment.setupInstallAddonFromFilePreference(settings)
+ assertTrue(preference.isVisible)
+ unmockkObject(Config)
+ }
+
+ @Test
+ @org.robolectric.annotation.Config(sdk = [Build.VERSION_CODES.P])
+ fun `Install add-on from file pref is invisible below Android 10`() = runTestOnMain {
+ val settingsFragment = SettingsFragment()
+ val activity = Robolectric.buildActivity(FragmentActivity::class.java).create().get()
+
+ activity.supportFragmentManager.beginTransaction()
+ .add(settingsFragment, "test")
+ .commitNow()
+
+ advanceUntilIdle()
+
+ val preference = settingsFragment.findPreference<Preference>(
+ settingsFragment.getPreferenceKey(R.string.pref_key_install_local_addon),
+ )
+
+ val settings: Settings = mockk(relaxed = true)
+
+ every { settings.showSecretDebugMenuThisSession } returns true
+ settingsFragment.setupInstallAddonFromFilePreference(settings)
+ assertFalse(preference!!.isVisible)
+ }
+
+ @Test
+ fun `Add-on collection override pref is visible if already configured and feature is enabled`() = runTestOnMain {
+ val settingsFragment = SettingsFragment()
+ val activity = Robolectric.buildActivity(FragmentActivity::class.java).create().get()
+
+ activity.supportFragmentManager.beginTransaction()
+ .add(settingsFragment, "test")
+ .commitNow()
+
+ advanceUntilIdle()
+
+ every { FeatureFlags.customExtensionCollectionFeature } returns true
+
+ val preferenceAmoCollectionOverride = settingsFragment.findPreference<Preference>(
+ settingsFragment.getPreferenceKey(R.string.pref_key_override_amo_collection),
+ )
+
+ settingsFragment.setupAmoCollectionOverridePreference(mockk(relaxed = true))
+ assertNotNull(preferenceAmoCollectionOverride)
+ assertFalse(preferenceAmoCollectionOverride!!.isVisible)
+
+ val settings: Settings = mockk(relaxed = true)
+ every { settings.showSecretDebugMenuThisSession } returns false
+
+ every { settings.amoCollectionOverrideConfigured() } returns false
+ settingsFragment.setupAmoCollectionOverridePreference(settings)
+ assertFalse(preferenceAmoCollectionOverride.isVisible)
+
+ every { settings.amoCollectionOverrideConfigured() } returns true
+ settingsFragment.setupAmoCollectionOverridePreference(settings)
+ assertTrue(preferenceAmoCollectionOverride.isVisible)
+ }
+
+ @Test
+ fun `Add-on collection override pref is not visible if feature is disabled`() = runTestOnMain {
+ val settingsFragment = SettingsFragment()
+ val activity = Robolectric.buildActivity(FragmentActivity::class.java).create().get()
+
+ activity.supportFragmentManager.beginTransaction()
+ .add(settingsFragment, "test")
+ .commitNow()
+
+ advanceUntilIdle()
+
+ every { FeatureFlags.customExtensionCollectionFeature } returns false
+
+ val preferenceAmoCollectionOverride = settingsFragment.findPreference<Preference>(
+ settingsFragment.getPreferenceKey(R.string.pref_key_override_amo_collection),
+ )
+
+ val settings: Settings = mockk(relaxed = true)
+ settingsFragment.setupAmoCollectionOverridePreference(settings)
+ assertNotNull(preferenceAmoCollectionOverride)
+ assertFalse(preferenceAmoCollectionOverride!!.isVisible)
+
+ every { settings.showSecretDebugMenuThisSession } returns true
+ every { settings.amoCollectionOverrideConfigured() } returns true
+ settingsFragment.setupAmoCollectionOverridePreference(settings)
+ assertFalse(preferenceAmoCollectionOverride.isVisible)
+ }
+
+ @Test
+ fun `GIVEN notifications are not allowed THEN set the appropriate summary to notification preferences`() {
+ val notificationPreference = settingsFragment.requirePreference<Preference>(
+ R.string.pref_key_notifications,
+ )
+ val summary = testContext.getString(R.string.notifications_not_allowed_summary)
+ mockkStatic(NotificationManagerCompat::class)
+ every { NotificationManagerCompat.from(any()).areNotificationsEnabled() } returns false
+ assertTrue(notificationPreference.summary.isNullOrEmpty())
+
+ settingsFragment.setupNotificationPreference()
+
+ assertEquals(summary, notificationPreference.summary)
+ unmockkStatic(NotificationManagerCompat::class)
+ }
+
+ @Test
+ fun `GIVEN notifications are allowed THEN set the appropriate summary to notification preferences`() {
+ val notificationPreference = settingsFragment.requirePreference<Preference>(
+ R.string.pref_key_notifications,
+ )
+ val summary = testContext.getString(R.string.notifications_allowed_summary)
+ mockkStatic(NotificationManagerCompat::class)
+ every { NotificationManagerCompat.from(any()).areNotificationsEnabled() } returns true
+ assertTrue(notificationPreference.summary.isNullOrEmpty())
+
+ settingsFragment.setupNotificationPreference()
+
+ assertEquals(summary, notificationPreference.summary)
+ unmockkStatic(NotificationManagerCompat::class)
+ }
+
+ @Test
+ fun `GIVEN the opening screen setting is set to homepage after four hours THEN set the appropriate summary to homepage preference`() {
+ val homepagePreference = settingsFragment.requirePreference<Preference>(
+ R.string.pref_key_home,
+ )
+ every { testContext.settings().alwaysOpenTheHomepageWhenOpeningTheApp } returns false
+ every { testContext.settings().openHomepageAfterFourHoursOfInactivity } returns true
+ every { testContext.settings().alwaysOpenTheLastTabWhenOpeningTheApp } returns false
+ assertTrue(homepagePreference.summary.isNullOrEmpty())
+ val summary =
+ testContext.getString(R.string.opening_screen_after_four_hours_of_inactivity_summary)
+
+ settingsFragment.setupHomepagePreference()
+
+ assertEquals(summary, homepagePreference.summary)
+ }
+
+ @Test
+ fun `GIVEN the opening screen setting is set to last tab THEN set the appropriate summary to homepage preference`() {
+ val homepagePreference = settingsFragment.requirePreference<Preference>(
+ R.string.pref_key_home,
+ )
+ every { testContext.settings().alwaysOpenTheHomepageWhenOpeningTheApp } returns false
+ every { testContext.settings().openHomepageAfterFourHoursOfInactivity } returns false
+ every { testContext.settings().alwaysOpenTheLastTabWhenOpeningTheApp } returns true
+ assertTrue(homepagePreference.summary.isNullOrEmpty())
+ val summary = testContext.getString(R.string.opening_screen_last_tab_summary)
+
+ settingsFragment.setupHomepagePreference()
+
+ assertEquals(summary, homepagePreference.summary)
+ }
+
+ @Test
+ fun `GIVEN the opening screen setting is set to homepage THEN set the appropriate summary to homepage preference`() {
+ val homepagePreference = settingsFragment.requirePreference<Preference>(
+ R.string.pref_key_home,
+ )
+ every { testContext.settings().alwaysOpenTheHomepageWhenOpeningTheApp } returns true
+ every { testContext.settings().openHomepageAfterFourHoursOfInactivity } returns false
+ every { testContext.settings().alwaysOpenTheLastTabWhenOpeningTheApp } returns false
+ assertTrue(homepagePreference.summary.isNullOrEmpty())
+ val summary = testContext.getString(R.string.opening_screen_homepage_summary)
+
+ settingsFragment.setupHomepagePreference()
+
+ assertEquals(summary, homepagePreference.summary)
+ }
+
+ @Test
+ fun `WHEN a custom search engine is set as default THEN it's name is set as summary for search preference`() {
+ val searchEngineName = "MySearchEngine"
+ val searchPreference = settingsFragment.requirePreference<Preference>(
+ R.string.pref_key_search_settings,
+ )
+ mockkStatic("mozilla.components.browser.state.state.SearchStateKt")
+ every { testContext.components.core.store.state.search } returns mockk(relaxed = true)
+ every { any<SearchState>().selectedOrDefaultSearchEngine } returns mockk {
+ every { name } returns searchEngineName
+ }
+ assertTrue(searchPreference.summary.isNullOrEmpty())
+
+ settingsFragment.setupSearchPreference()
+
+ assertEquals(searchEngineName, searchPreference.summary)
+ }
+
+ @Test
+ fun `GIVEN the tracking protection preference is set to custom THEN set the appropriate summary`() {
+ val trackingProtectionPreference = settingsFragment.requirePreference<Preference>(
+ R.string.pref_key_tracking_protection_settings,
+ )
+ every { testContext.settings().shouldUseTrackingProtection } returns true
+ every { testContext.settings().useStandardTrackingProtection } returns false
+ every { testContext.settings().useStrictTrackingProtection } returns false
+ every { testContext.settings().useCustomTrackingProtection } returns true
+ assertTrue(trackingProtectionPreference.summary.isNullOrEmpty())
+ val summary = testContext.getString(R.string.tracking_protection_custom)
+
+ settingsFragment.setupTrackingProtectionPreference()
+
+ assertEquals(summary, trackingProtectionPreference.summary)
+ }
+
+ @Test
+ fun `GIVEN the tracking protection preference is set to strict THEN set the appropriate summary`() {
+ val trackingProtectionPreference = settingsFragment.requirePreference<Preference>(
+ R.string.pref_key_tracking_protection_settings,
+ )
+ every { testContext.settings().shouldUseTrackingProtection } returns true
+ every { testContext.settings().useStandardTrackingProtection } returns false
+ every { testContext.settings().useStrictTrackingProtection } returns true
+ every { testContext.settings().useCustomTrackingProtection } returns false
+ assertTrue(trackingProtectionPreference.summary.isNullOrEmpty())
+ val summary = testContext.getString(R.string.tracking_protection_strict)
+
+ settingsFragment.setupTrackingProtectionPreference()
+
+ assertEquals(summary, trackingProtectionPreference.summary)
+ }
+
+ @Test
+ fun `GIVEN the tracking protection preference is set to standard THEN set the appropriate summary`() {
+ val trackingProtectionPreference = settingsFragment.requirePreference<Preference>(
+ R.string.pref_key_tracking_protection_settings,
+ )
+ every { testContext.settings().shouldUseTrackingProtection } returns true
+ every { testContext.settings().useStandardTrackingProtection } returns true
+ every { testContext.settings().useStrictTrackingProtection } returns false
+ every { testContext.settings().useCustomTrackingProtection } returns false
+ assertTrue(trackingProtectionPreference.summary.isNullOrEmpty())
+ val summary = testContext.getString(R.string.tracking_protection_standard)
+
+ settingsFragment.setupTrackingProtectionPreference()
+
+ assertEquals(summary, trackingProtectionPreference.summary)
+ }
+
+ @Test
+ fun `GIVEN the tracking protection preference is disabled THEN set the appropriate summary`() {
+ val trackingProtectionPreference = settingsFragment.requirePreference<Preference>(
+ R.string.pref_key_tracking_protection_settings,
+ )
+ every { testContext.settings().shouldUseTrackingProtection } returns false
+ assertTrue(trackingProtectionPreference.summary.isNullOrEmpty())
+ val summary = testContext.getString(R.string.tracking_protection_off)
+
+ settingsFragment.setupTrackingProtectionPreference()
+
+ assertEquals(summary, trackingProtectionPreference.summary)
+ }
+
+ @Test
+ fun `GIVEN the HttpsOnly is set to private tabs THEN set the appropriate preference summary`() {
+ val httpsOnlyPreference = settingsFragment.findPreference<Preference>(
+ settingsFragment.getPreferenceKey(R.string.pref_key_https_only_settings),
+ )!!
+ every { testContext.settings().shouldUseHttpsOnly } returns true
+ every { testContext.settings().shouldUseHttpsOnlyInPrivateTabsOnly } returns true
+ every { testContext.settings().shouldUseHttpsOnlyInAllTabs } returns false
+ assertTrue(httpsOnlyPreference.summary.isNullOrEmpty())
+ val summary = testContext.getString(R.string.preferences_https_only_on_private)
+
+ settingsFragment.setupHttpsOnlyPreferences()
+
+ assertEquals(summary, httpsOnlyPreference.summary)
+ }
+
+ @Test
+ fun `GIVEN the HttpsOnly is set to all tabs THEN set the appropriate preference summary`() {
+ val httpsOnlyPreference = settingsFragment.findPreference<Preference>(
+ settingsFragment.getPreferenceKey(R.string.pref_key_https_only_settings),
+ )!!
+ every { testContext.settings().shouldUseHttpsOnly } returns true
+ every { testContext.settings().shouldUseHttpsOnlyInAllTabs } returns true
+ every { testContext.settings().shouldUseHttpsOnlyInPrivateTabsOnly } returns false
+ assertTrue(httpsOnlyPreference.summary.isNullOrEmpty())
+ val summary = testContext.getString(R.string.preferences_https_only_on_all)
+
+ settingsFragment.setupHttpsOnlyPreferences()
+
+ assertEquals(summary, httpsOnlyPreference.summary)
+ }
+
+ @Test
+ fun `GIVEN the HttpsOnly is disabled THEN set the appropriate preference summary`() {
+ val httpsOnlyPreference = settingsFragment.findPreference<Preference>(
+ settingsFragment.getPreferenceKey(R.string.pref_key_https_only_settings),
+ )!!
+ every { testContext.settings().shouldUseHttpsOnly } returns false
+ assertTrue(httpsOnlyPreference.summary.isNullOrEmpty())
+ val summary = testContext.getString(R.string.preferences_https_only_off)
+
+ settingsFragment.setupHttpsOnlyPreferences()
+
+ assertEquals(summary, httpsOnlyPreference.summary)
+ }
+
+ @Test
+ fun `GIVEN an account observer WHEN the fragment is visible THEN register it for updates`() {
+ val accountManager: FxaAccountManager = mockk(relaxed = true)
+ every { testContext.components.backgroundServices.accountManager } returns accountManager
+
+ settingsFragment.onStart()
+
+ verify { accountManager.register(settingsFragment.accountObserver, settingsFragment, true) }
+ }
+
+ @Test
+ fun `GIVEN an account observer WHEN the fragment stops being visible THEN unregister it for updates`() {
+ val accountManager: FxaAccountManager = mockk(relaxed = true)
+ every { testContext.components.backgroundServices.accountManager } returns accountManager
+
+ settingsFragment.onStop()
+
+ verify { accountManager.unregister(settingsFragment.accountObserver) }
+ }
+
+ @After
+ fun tearDown() {
+ unmockkObject(FeatureFlags)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/SupportUtilsTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/SupportUtilsTest.kt
new file mode 100644
index 0000000000..f292038e52
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/SupportUtilsTest.kt
@@ -0,0 +1,71 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings
+
+import android.content.Context
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.util.Locale
+
+class SupportUtilsTest {
+
+ @Test
+ fun getSumoURLForTopic() {
+ assertEquals(
+ "https://support.mozilla.org/1/mobile/1.6/Android/en-US/common-myths-about-private-browsing",
+ SupportUtils.getSumoURLForTopic(mockContext("1.6"), SupportUtils.SumoTopic.PRIVATE_BROWSING_MYTHS, Locale("en", "US")),
+ )
+ assertEquals(
+ "https://support.mozilla.org/1/mobile/20/Android/fr/tracking-protection-firefox-android",
+ SupportUtils.getSumoURLForTopic(mockContext("2 0"), SupportUtils.SumoTopic.TRACKING_PROTECTION, Locale("fr")),
+ )
+ assertEquals(
+ "https://www.mozilla.org/firefox/android/notes",
+ SupportUtils.WHATS_NEW_URL,
+ )
+ }
+
+ @Test
+ fun getGenericSumoURLForTopic() {
+ assertEquals(
+ "https://support.mozilla.org/en-GB/kb/faq-android",
+ SupportUtils.getGenericSumoURLForTopic(SupportUtils.SumoTopic.HELP, Locale("en", "GB")),
+ )
+ assertEquals(
+ "https://support.mozilla.org/de/kb/your-rights",
+ SupportUtils.getGenericSumoURLForTopic(SupportUtils.SumoTopic.YOUR_RIGHTS, Locale("de")),
+ )
+ }
+
+ @Test
+ fun getMozillaPageUrl() {
+ assertEquals(
+ "https://www.mozilla.org/en-US/about/manifesto/",
+ SupportUtils.getMozillaPageUrl(SupportUtils.MozillaPage.MANIFESTO, Locale("en", "US")),
+ )
+ assertEquals(
+ "https://www.mozilla.org/zh/privacy/firefox/",
+ SupportUtils.getMozillaPageUrl(SupportUtils.MozillaPage.PRIVATE_NOTICE, Locale("zh")),
+ )
+ }
+
+ private fun mockContext(versionName: String): Context {
+ val context: Context = mockk()
+ val packageManager: PackageManager = mockk()
+ val packageInfo = PackageInfo()
+
+ every { context.packageName } returns "org.mozilla.fenix"
+ every { context.packageManager } returns packageManager
+ @Suppress("DEPRECATION")
+ every { packageManager.getPackageInfo("org.mozilla.fenix", 0) } returns packageInfo
+ packageInfo.versionName = versionName
+
+ return context
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/TrackingProtectionFragmentTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/TrackingProtectionFragmentTest.kt
new file mode 100644
index 0000000000..f4674c12d4
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/TrackingProtectionFragmentTest.kt
@@ -0,0 +1,63 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings
+
+import androidx.fragment.app.FragmentActivity
+import io.mockk.every
+import io.mockk.mockk
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.utils.Settings
+import org.robolectric.Robolectric
+
+@RunWith(FenixRobolectricTestRunner::class)
+class TrackingProtectionFragmentTest {
+
+ @Test
+ fun `UI component should match settings defaults`() {
+ val settings = Settings(testContext)
+ every { testContext.components.analytics } returns mockk(relaxed = true)
+ every { testContext.components.settings } returns settings
+ val settingsFragment = TrackingProtectionFragment()
+ val activity = Robolectric.buildActivity(FragmentActivity::class.java).create().get()
+
+ activity.supportFragmentManager.beginTransaction()
+ .add(settingsFragment, "settingsFragment")
+ .commitNow()
+
+ val customCookiesCheckBox = settingsFragment.customCookies.isChecked
+ val customCookiesCheckBoxSettings = settings.blockCookiesInCustomTrackingProtection
+
+ val customCookiesSelect = settingsFragment.customCookiesSelect.value
+ val customCookiesSelectSettings = settings.blockCookiesSelectionInCustomTrackingProtection
+
+ val customTrackingContentCheckBox = settingsFragment.customTracking.isChecked
+ val customTrackingContentCheckBoxSettings = settings.blockTrackingContentInCustomTrackingProtection
+
+ val customTrackingContentSelect = settingsFragment.customTrackingSelect.value
+ val customTrackingContentSelectSettings = settings.blockTrackingContentSelectionInCustomTrackingProtection
+
+ val customCryptominersCheckBox = settingsFragment.customCryptominers.isChecked
+ val customCryptominersCheckBoxSettings = settings.blockCryptominersInCustomTrackingProtection
+
+ val customFingerprintersCheckBox = settingsFragment.customFingerprinters.isChecked
+ val customFingerprintersCheckBoxSettings = settings.blockFingerprintersInCustomTrackingProtection
+
+ val customRedirectTrackersCheckBox = settingsFragment.customRedirectTrackers.isChecked
+ val customRedirectTrackersCheckBoxSettings = settings.blockRedirectTrackersInCustomTrackingProtection
+
+ assertEquals(customCookiesCheckBoxSettings, customCookiesCheckBox)
+ assertEquals(customCookiesSelectSettings, customCookiesSelect)
+ assertEquals(customTrackingContentCheckBoxSettings, customTrackingContentCheckBox)
+ assertEquals(customTrackingContentSelect, customTrackingContentSelectSettings)
+ assertEquals(customCryptominersCheckBoxSettings, customCryptominersCheckBox)
+ assertEquals(customFingerprintersCheckBoxSettings, customFingerprintersCheckBox)
+ assertEquals(customRedirectTrackersCheckBoxSettings, customRedirectTrackersCheckBox)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/about/AboutLibrariesFragmentTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/about/AboutLibrariesFragmentTest.kt
new file mode 100644
index 0000000000..5b74100229
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/about/AboutLibrariesFragmentTest.kt
@@ -0,0 +1,49 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.about
+
+import android.widget.ListView
+import android.widget.TextView
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.createAddedTestFragmentInNavHostActivity
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.shadows.ShadowAlertDialog
+
+@RunWith(FenixRobolectricTestRunner::class)
+class AboutLibrariesFragmentTest {
+ private lateinit var fragment: AboutLibrariesFragment
+ private lateinit var librariesListView: ListView
+
+ @Before
+ fun setup() {
+ fragment = createAddedTestFragmentInNavHostActivity { AboutLibrariesFragment() }
+ librariesListView = fragment.requireView().findViewById(R.id.about_libraries_listview)
+ }
+
+ @Test
+ fun `fragment should display licenses`() {
+ assertTrue(0 < librariesListView.count)
+ }
+
+ @Test
+ fun `item click should open license dialog`() {
+ val listViewShadow = shadowOf(librariesListView)
+ listViewShadow.clickFirstItemContainingText("org.mozilla.geckoview:geckoview")
+
+ val alertDialogShadow = ShadowAlertDialog.getLatestDialog()
+ assertTrue(alertDialogShadow.isShowing)
+
+ val alertDialogText = alertDialogShadow
+ .findViewById<TextView>(android.R.id.message)
+ .text
+ .toString()
+ assertTrue(alertDialogText.contains("MPL"))
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/about/AboutPageAdapterTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/about/AboutPageAdapterTest.kt
new file mode 100644
index 0000000000..1b6215b62d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/about/AboutPageAdapterTest.kt
@@ -0,0 +1,93 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.about
+
+import android.view.ViewGroup
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.databinding.AboutListItemBinding
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.about.viewholders.AboutItemViewHolder
+
+@RunWith(FenixRobolectricTestRunner::class)
+class AboutPageAdapterTest {
+ private val aboutList: List<AboutPageItem> =
+ listOf(
+ AboutPageItem(
+ AboutItem.ExternalLink(
+ AboutItemType.WHATS_NEW,
+ "https://mozilla.org",
+ ),
+ "Libraries",
+ ),
+ AboutPageItem(AboutItem.Libraries, "Libraries"),
+ AboutPageItem(AboutItem.Crashes, "Crashes"),
+ )
+ private val listener: AboutPageListener = mockk(relaxed = true)
+
+ @Test
+ fun `getItemCount on a default instantiated Adapter should return 0`() {
+ val adapter = AboutPageAdapter(listener)
+
+ assertEquals(0, adapter.itemCount)
+ }
+
+ @Test
+ fun `getItemCount after updateData() call should return the correct list size`() {
+ val adapter = AboutPageAdapter(listener)
+
+ adapter.submitList(aboutList)
+
+ assertEquals(3, adapter.itemCount)
+ }
+
+ @Test
+ fun `the adapter uses AboutItemViewHolder`() {
+ val adapter = AboutPageAdapter(listener)
+ val parentView: ViewGroup = mockk(relaxed = true)
+ every { parentView.context } returns testContext
+
+ val viewHolder = adapter.onCreateViewHolder(parentView, AboutItemViewHolder.LAYOUT_ID)
+
+ assertEquals(AboutItemViewHolder::class, viewHolder::class)
+ }
+
+ @Test
+ fun `the adapter binds the right item to a ViewHolder`() {
+ val adapter = AboutPageAdapter(listener)
+ val parentView: ViewGroup = mockk(relaxed = true)
+
+ mockkStatic(AboutListItemBinding::class)
+ val binding: AboutListItemBinding = mockk()
+
+ every { AboutListItemBinding.bind(parentView) } returns binding
+ every { binding.root } returns mockk()
+
+ val viewHolder = spyk(AboutItemViewHolder(parentView, mockk()))
+
+ every {
+ adapter.onCreateViewHolder(
+ parentView,
+ AboutItemViewHolder.LAYOUT_ID,
+ )
+ } returns viewHolder
+
+ every { viewHolder.bind(any()) } just Runs
+
+ adapter.submitList(aboutList)
+ adapter.bindViewHolder(viewHolder, 1)
+
+ verify { viewHolder.bind(aboutList[1]) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/about/SecretDebugMenuTriggerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/about/SecretDebugMenuTriggerTest.kt
new file mode 100644
index 0000000000..da959c19ca
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/about/SecretDebugMenuTriggerTest.kt
@@ -0,0 +1,121 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.about
+
+import android.content.Context
+import android.view.View
+import android.widget.Toast
+import io.mockk.CapturingSlot
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.slot
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.R
+import org.mozilla.fenix.utils.Settings
+
+class SecretDebugMenuTriggerTest {
+
+ @MockK private lateinit var logoView: View
+
+ @MockK private lateinit var context: Context
+
+ @MockK private lateinit var settings: Settings
+
+ @MockK(relaxUnitFun = true)
+ private lateinit var toast: Toast
+ private lateinit var clickListener: CapturingSlot<View.OnClickListener>
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ mockkStatic(Toast::class)
+ clickListener = slot()
+
+ every { logoView.setOnClickListener(capture(clickListener)) } just Runs
+ every { logoView.context } returns context
+ every {
+ context.getString(R.string.about_debug_menu_toast_progress, any())
+ } returns "Debug menu: x click(s) left to enable"
+ every { settings.showSecretDebugMenuThisSession } returns false
+ every { settings.showSecretDebugMenuThisSession = any() } just Runs
+ every { Toast.makeText(context, any<Int>(), any()) } returns toast
+ every { Toast.makeText(context, any<String>(), any()) } returns toast
+ }
+
+ @After
+ fun teardown() {
+ unmockkStatic(Toast::class)
+ }
+
+ @Test
+ fun `toast is not displayed on first click`() {
+ SecretDebugMenuTrigger(logoView, settings)
+ clickListener.captured.onClick(logoView)
+
+ verify(inverse = true) { Toast.makeText(context, any<String>(), any()) }
+ verify(inverse = true) { toast.show() }
+ }
+
+ @Test
+ fun `toast is displayed on second click`() {
+ SecretDebugMenuTrigger(logoView, settings)
+ clickListener.captured.onClick(logoView)
+ clickListener.captured.onClick(logoView)
+
+ verify { context.getString(R.string.about_debug_menu_toast_progress, 3) }
+ verify { Toast.makeText(context, any<String>(), Toast.LENGTH_SHORT) }
+ verify { toast.show() }
+ }
+
+ @Test
+ fun `clearClickCounter resets counter`() {
+ val trigger = SecretDebugMenuTrigger(logoView, settings)
+
+ clickListener.captured.onClick(logoView)
+ trigger.onResume(mockk())
+
+ clickListener.captured.onClick(logoView)
+
+ verify(inverse = true) { Toast.makeText(context, any<String>(), any()) }
+ verify(inverse = true) { toast.show() }
+ }
+
+ @Test
+ fun `toast is displayed on fifth click`() {
+ SecretDebugMenuTrigger(logoView, settings)
+ clickListener.captured.onClick(logoView)
+ clickListener.captured.onClick(logoView)
+ clickListener.captured.onClick(logoView)
+ clickListener.captured.onClick(logoView)
+ clickListener.captured.onClick(logoView)
+
+ verify {
+ Toast.makeText(
+ context,
+ R.string.about_debug_menu_toast_done,
+ Toast.LENGTH_LONG,
+ )
+ }
+ verify { toast.show() }
+ verify { settings.showSecretDebugMenuThisSession = true }
+ }
+
+ @Test
+ fun `don't register click listener if menu is already shown`() {
+ every { settings.showSecretDebugMenuThisSession } returns true
+ SecretDebugMenuTrigger(logoView, settings)
+
+ verify(inverse = true) { logoView.setOnClickListener(any()) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/about/viewholders/AboutItemViewHolderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/about/viewholders/AboutItemViewHolderTest.kt
new file mode 100644
index 0000000000..a292a0d7c5
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/about/viewholders/AboutItemViewHolderTest.kt
@@ -0,0 +1,50 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.about.viewholders
+
+import android.view.LayoutInflater
+import android.view.View
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.about.AboutItem
+import org.mozilla.fenix.settings.about.AboutPageItem
+import org.mozilla.fenix.settings.about.AboutPageListener
+
+@RunWith(FenixRobolectricTestRunner::class)
+class AboutItemViewHolderTest {
+
+ private val item = AboutPageItem(AboutItem.Libraries, "Libraries")
+ private lateinit var view: View
+ private lateinit var listener: AboutPageListener
+
+ @Before
+ fun setup() {
+ view = LayoutInflater.from(testContext).inflate(AboutItemViewHolder.LAYOUT_ID, null)
+ listener = mockk(relaxed = true)
+ }
+
+ @Test
+ fun `bind title`() {
+ val holder = AboutItemViewHolder(view, listener)
+ holder.bind(item)
+
+ assertEquals("Libraries", holder.binding.aboutItemTitle.text)
+ }
+
+ @Test
+ fun `call listener on click`() {
+ val holder = AboutItemViewHolder(view, listener)
+ holder.bind(item)
+ holder.itemView.performClick()
+
+ verify { listener.onAboutItemClicked(AboutItem.Libraries) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/account/AccountSettingsFragmentStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/account/AccountSettingsFragmentStoreTest.kt
new file mode 100644
index 0000000000..01aaad0d2a
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/account/AccountSettingsFragmentStoreTest.kt
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.account
+
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotSame
+import org.junit.Test
+
+class AccountSettingsFragmentStoreTest {
+
+ @Test
+ fun syncFailed() = runTest {
+ val initialState = AccountSettingsFragmentState()
+ val store = AccountSettingsFragmentStore(initialState)
+ val duration = 1L
+
+ store.dispatch(AccountSettingsFragmentAction.SyncFailed(duration)).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(LastSyncTime.Failed(duration), store.state.lastSyncedDate)
+ }
+
+ @Test
+ fun syncEnded() = runTest {
+ val initialState = AccountSettingsFragmentState()
+ val store = AccountSettingsFragmentStore(initialState)
+ val duration = 1L
+
+ store.dispatch(AccountSettingsFragmentAction.SyncEnded(duration)).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(LastSyncTime.Success(duration), store.state.lastSyncedDate)
+ }
+
+ @Test
+ fun signOut() = runTest {
+ val initialState = AccountSettingsFragmentState()
+ val store = AccountSettingsFragmentStore(initialState)
+ val deviceName = "testing"
+
+ store.dispatch(AccountSettingsFragmentAction.UpdateDeviceName(deviceName)).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(deviceName, store.state.deviceName)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/account/AccountSettingsInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/account/AccountSettingsInteractorTest.kt
new file mode 100644
index 0000000000..4fc81b61bb
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/account/AccountSettingsInteractorTest.kt
@@ -0,0 +1,96 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.account
+
+import androidx.navigation.NavController
+import androidx.navigation.NavDestination
+import io.mockk.Called
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.mozilla.fenix.R
+
+class AccountSettingsInteractorTest {
+
+ @Test
+ fun onSyncNow() {
+ var ranSyncNow = false
+
+ val interactor = AccountSettingsInteractor(
+ mockk(),
+ { ranSyncNow = true },
+ { false },
+ mockk(),
+ )
+
+ interactor.onSyncNow()
+
+ assertEquals(ranSyncNow, true)
+ }
+
+ @Test
+ fun onChangeDeviceName() {
+ val store: AccountSettingsFragmentStore = mockk(relaxed = true)
+ var invalidResponseInvoked = false
+ val invalidNameResponse = { invalidResponseInvoked = true }
+
+ val interactor = AccountSettingsInteractor(
+ mockk(),
+ { },
+ { true },
+ store,
+ )
+
+ assertTrue(interactor.onChangeDeviceName("New Name", invalidNameResponse))
+
+ verify { store.dispatch(AccountSettingsFragmentAction.UpdateDeviceName("New Name")) }
+ assertFalse(invalidResponseInvoked)
+ }
+
+ @Test
+ fun onChangeDeviceNameSyncFalse() {
+ val store: AccountSettingsFragmentStore = mockk(relaxed = true)
+ var invalidResponseInvoked = false
+ val invalidNameResponse = { invalidResponseInvoked = true }
+
+ val interactor = AccountSettingsInteractor(
+ mockk(),
+ { },
+ { false },
+ store,
+ )
+
+ assertFalse(interactor.onChangeDeviceName("New Name", invalidNameResponse))
+
+ verify { store wasNot Called }
+ assertTrue(invalidResponseInvoked)
+ }
+
+ @Test
+ fun onSignOut() {
+ val navController: NavController = mockk(relaxed = true)
+ every { navController.currentDestination } returns NavDestination("").apply { id = R.id.accountSettingsFragment }
+
+ val interactor = AccountSettingsInteractor(
+ navController,
+ { },
+ { false },
+ mockk(),
+ )
+
+ interactor.onSignOut()
+
+ verify {
+ navController.navigate(
+ AccountSettingsFragmentDirections.actionAccountSettingsFragmentToSignOutFragment(),
+ null,
+ )
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/account/AuthCustomTabActivityTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/account/AuthCustomTabActivityTest.kt
new file mode 100644
index 0000000000..7a08fc77a3
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/account/AuthCustomTabActivityTest.kt
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.account
+
+import android.content.Intent
+import androidx.navigation.NavController
+import io.mockk.Called
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.BrowserDirection
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class AuthCustomTabActivityTest {
+
+ @Test
+ fun `navigateToBrowserOnColdStart does nothing for AuthCustomTabActivity`() {
+ val activity = spyk(AuthCustomTabActivity())
+
+ val settings: Settings = mockk()
+ every { settings.shouldReturnToBrowser } returns true
+ every { activity.components.settings.shouldReturnToBrowser } returns true
+ every { activity.openToBrowser(any(), any()) } returns Unit
+
+ activity.navigateToBrowserOnColdStart()
+
+ verify(exactly = 0) { activity.openToBrowser(BrowserDirection.FromGlobal) }
+ }
+
+ @Test
+ fun `navigateToHome does nothing for AuthCustomTabActivity`() {
+ val activity = spyk(AuthCustomTabActivity())
+ val navHostController: NavController = mockk()
+
+ activity.navigateToHome(navHostController)
+ verify { navHostController wasNot Called }
+ }
+
+ @Test
+ fun `handleNewIntent does nothing for AuthCustomTabActivity`() {
+ val activity = spyk(AuthCustomTabActivity())
+ val intent: Intent = mockk(relaxed = true)
+
+ activity.handleNewIntent(intent)
+ verify { intent wasNot Called }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/account/DefaultSyncControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/account/DefaultSyncControllerTest.kt
new file mode 100644
index 0000000000..53c310e8d0
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/account/DefaultSyncControllerTest.kt
@@ -0,0 +1,42 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.account
+
+import androidx.appcompat.app.AlertDialog
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.HomeActivity
+
+class DefaultSyncControllerTest {
+
+ private lateinit var syncController: DefaultSyncController
+
+ @MockK(relaxed = true)
+ private lateinit var activity: HomeActivity
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+ syncController = DefaultSyncController(activity)
+ }
+
+ @Test
+ fun `show camera permissions needed dialog`() {
+ val dialogBuilder: AlertDialog.Builder = mockk(relaxed = true)
+
+ val spyController = spyk(syncController)
+ every { spyController.buildDialog() } returns dialogBuilder
+
+ spyController.handleCameraPermissionsNeeded()
+
+ verify { dialogBuilder.show() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/account/DefaultSyncInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/account/DefaultSyncInteractorTest.kt
new file mode 100644
index 0000000000..f00756275a
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/account/DefaultSyncInteractorTest.kt
@@ -0,0 +1,31 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.account
+
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+
+class DefaultSyncInteractorTest {
+
+ private lateinit var syncInteractor: DefaultSyncInteractor
+ private lateinit var syncController: DefaultSyncController
+
+ @Before
+ fun setUp() {
+ syncController = mockk(relaxed = true)
+ syncInteractor = DefaultSyncInteractor(syncController)
+ }
+
+ @Test
+ fun onCameraPermissionsNeeded() {
+ syncInteractor.onCameraPermissionsNeeded()
+
+ verify {
+ syncController.handleCameraPermissionsNeeded()
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/AddressEditorViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/AddressEditorViewTest.kt
new file mode 100644
index 0000000000..1705d773b7
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/AddressEditorViewTest.kt
@@ -0,0 +1,346 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.address
+
+import android.view.LayoutInflater
+import android.view.View
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import kotlinx.coroutines.runBlocking
+import mozilla.components.browser.state.search.RegionState
+import mozilla.components.concept.storage.Address
+import mozilla.components.concept.storage.UpdatableAddressFields
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.Addresses
+import org.mozilla.fenix.R
+import org.mozilla.fenix.databinding.FragmentAddressEditorBinding
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.address.interactor.AddressEditorInteractor
+import org.mozilla.fenix.settings.address.view.AddressEditorView
+
+@RunWith(FenixRobolectricTestRunner::class) // For gleanTestRule
+class AddressEditorViewTest {
+
+ private lateinit var view: View
+ private lateinit var interactor: AddressEditorInteractor
+ private lateinit var addressEditorView: AddressEditorView
+ private lateinit var binding: FragmentAddressEditorBinding
+ private lateinit var address: Address
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @Before
+ fun setup() {
+ view = LayoutInflater.from(testContext).inflate(R.layout.fragment_address_editor, null)
+ binding = FragmentAddressEditorBinding.bind(view)
+ interactor = mockk(relaxed = true)
+ address = mockk(relaxed = true)
+ every { address.guid } returns "123"
+
+ addressEditorView = spyk(AddressEditorView(binding, interactor))
+ }
+
+ @Test
+ fun `GIVEN an existing address WHEN the save button is clicked THEN interactor updates address`() {
+ val country = AddressUtils.countries["US"]!!
+ val address = generateAddress(country = country.countryCode, addressLevel1 = country.subregions[0])
+ val addressEditorView = AddressEditorView(
+ binding = binding,
+ interactor = interactor,
+ address = address,
+ )
+
+ addressEditorView.bind()
+ addressEditorView.saveAddress()
+
+ val expected = UpdatableAddressFields(
+ name = address.name,
+ organization = "",
+ streetAddress = address.streetAddress,
+ addressLevel3 = "",
+ addressLevel2 = address.addressLevel2,
+ addressLevel1 = address.addressLevel1,
+ postalCode = address.postalCode,
+ country = address.country,
+ tel = address.tel,
+ email = address.email,
+ )
+ verify { interactor.onUpdateAddress(address.guid, expected) }
+ }
+
+ @Test
+ fun `GIVEN a new address WHEN the save button is clicked THEN interactor saves new address`() {
+ val addressEditorView = AddressEditorView(
+ binding = binding,
+ interactor = interactor,
+ )
+
+ addressEditorView.bind()
+ addressEditorView.saveAddress()
+
+ val expected = UpdatableAddressFields(
+ name = "",
+ organization = "",
+ streetAddress = "",
+ addressLevel3 = "",
+ addressLevel2 = "",
+ addressLevel1 = "Alabama",
+ postalCode = "",
+ country = "US",
+ tel = "",
+ email = "",
+ )
+ verify { interactor.onSaveAddress(expected) }
+ }
+
+ @Test
+ fun `WHEN the cancel button is clicked THEN interactor is called`() {
+ addressEditorView.bind()
+
+ binding.cancelButton.performClick()
+
+ verify { interactor.onCancelButtonClicked() }
+ }
+
+ @Test
+ fun `GIVEN an existing address WHEN editor is opened THEN the form fields are correctly mapped to the address fields`() {
+ val address = generateAddress()
+
+ val addressEditorView = spyk(
+ AddressEditorView(
+ binding = binding,
+ interactor = interactor,
+ address = address,
+ ),
+ )
+ addressEditorView.bind()
+
+ assertEquals("PostalCode", binding.zipInput.text.toString())
+ assertEquals(address.addressLevel1, binding.subregionDropDown.selectedItem.toString())
+ assertEquals("City", binding.cityInput.text.toString())
+ assertEquals("Street", binding.streetAddressInput.text.toString())
+ assertEquals("Name", binding.nameInput.text.toString())
+ assertEquals("email@mozilla.com", binding.emailInput.text.toString())
+ assertEquals("Telephone", binding.phoneInput.text.toString())
+ }
+
+ @Test
+ fun `GIVEN an existing address WHEN editor is opened THEN the delete address button is visible`() = runBlocking {
+ val addressEditorView = spyk(
+ AddressEditorView(
+ binding = binding,
+ interactor = interactor,
+ address = address,
+ ),
+ )
+ addressEditorView.bind()
+
+ assertEquals(View.VISIBLE, binding.deleteButton.visibility)
+ }
+
+ @Test
+ fun `GIVEN an existing address WHEN the delete address button is clicked THEN confirm delete dialog is shown`() = runBlocking {
+ val addressEditorView = spyk(
+ AddressEditorView(
+ binding = binding,
+ interactor = interactor,
+ address = address,
+ ),
+ )
+ addressEditorView.bind()
+
+ binding.deleteButton.performClick()
+
+ verify { addressEditorView.showConfirmDeleteAddressDialog(view.context, "123") }
+ }
+
+ @Test
+ fun `GIVEN existing address with correct subregion and country WHEN subregion dropdown is bound THEN adapter sets subregion dropdown to address`() {
+ val address = generateAddress(country = "US", addressLevel1 = "Oregon")
+
+ val addressEditorView = AddressEditorView(
+ binding = binding,
+ interactor = interactor,
+ address = address,
+ )
+ addressEditorView.bind()
+
+ assertEquals("Oregon", binding.subregionDropDown.selectedItem.toString())
+ }
+
+ @Test
+ fun `GIVEN existing address subregion outside of country WHEN subregion dropdown is bound THEN dropdown defaults to first subregion entry for country`() {
+ val address = generateAddress(country = "CA", addressLevel1 = "Alabama")
+
+ val addressEditorView = AddressEditorView(
+ binding = binding,
+ interactor = interactor,
+ address = address,
+ )
+ addressEditorView.bind()
+
+ assertEquals("Alberta", binding.subregionDropDown.selectedItem.toString())
+ }
+
+ @Test
+ fun `GIVEN no existing address WHEN subregion dropdown is bound THEN dropdown defaults to first subregion of default country`() {
+ val addressEditorView = AddressEditorView(
+ binding = binding,
+ interactor = interactor,
+ )
+ addressEditorView.bind()
+
+ assertEquals("Alabama", binding.subregionDropDown.selectedItem.toString())
+ }
+
+ @Test
+ fun `WHEN country is changed THEN available subregions are updated`() {
+ val addressEditorView = AddressEditorView(
+ binding = binding,
+ interactor = interactor,
+ )
+ addressEditorView.bind()
+
+ assertEquals("Alabama", binding.subregionDropDown.selectedItem.toString())
+ binding.countryDropDown.setSelection(0)
+ assertNotEquals("Alabama", binding.subregionDropDown.selectedItem.toString())
+ }
+
+ @Test
+ fun `GIVEN existing address not in available countries WHEN view is bound THEN country and subregion dropdowns are set to default `() {
+ val address = generateAddress(country = "I AM NOT A COUNTRY", addressLevel1 = "I AM NOT A STATE")
+ val addressEditorView = AddressEditorView(
+ binding = binding,
+ interactor = interactor,
+ address = address,
+ )
+ addressEditorView.bind()
+
+ assertEquals("United States", binding.countryDropDown.selectedItem.toString())
+ assertEquals("Alabama", binding.subregionDropDown.selectedItem.toString())
+ }
+
+ @Test
+ fun `GIVEN existing address WHEN country dropdown is bound THEN adapter sets country dropdown to address`() {
+ val addressEditorView = spyk(
+ AddressEditorView(
+ binding = binding,
+ interactor = interactor,
+ address = generateAddress(country = "CA"),
+ ),
+ )
+ addressEditorView.bind()
+
+ assertEquals(AddressUtils.countries["CA"]?.displayName, binding.countryDropDown.selectedItem.toString())
+ }
+
+ @Test
+ fun `GIVEN existing address and region not in supported countries WHEN country dropdown is bound THEN adapter sets dropdown to lower priority`() {
+ val addressEditorView = spyk(
+ AddressEditorView(
+ binding = binding,
+ interactor = interactor,
+ region = RegionState.Default,
+ address = generateAddress(country = "XX"),
+ ),
+ )
+ addressEditorView.bind()
+
+ assertEquals(AddressUtils.countries[DEFAULT_COUNTRY]!!.displayName, binding.countryDropDown.selectedItem.toString())
+ }
+
+ @Test
+ fun `GIVEN search region and no address WHEN country dropdown is bound THEN adapter sets country dropdown to home region`() {
+ val addressEditorView = AddressEditorView(
+ binding = binding,
+ interactor = interactor,
+ region = RegionState("CA", "US"),
+ address = null,
+ )
+ addressEditorView.bind()
+
+ assertEquals(AddressUtils.countries["CA"]?.displayName, binding.countryDropDown.selectedItem.toString())
+ }
+
+ @Test
+ fun `GIVEN search region not in supported countries WHEN country dropdown is bound THEN adapter sets dropdown to lower priority`() {
+ val addressEditorView = AddressEditorView(
+ binding = binding,
+ interactor = interactor,
+ region = RegionState.Default,
+ address = null,
+ )
+ addressEditorView.bind()
+
+ assertEquals(AddressUtils.countries[DEFAULT_COUNTRY]!!.displayName, binding.countryDropDown.selectedItem.toString())
+ }
+
+ @Test
+ fun `GIVEN no address or search region WHEN country dropdown is bound THEN adapter sets dropdown to default`() {
+ val addressEditorView = AddressEditorView(
+ binding = binding,
+ interactor = interactor,
+ region = null,
+ address = null,
+ )
+ addressEditorView.bind()
+
+ assertEquals(AddressUtils.countries[DEFAULT_COUNTRY]!!.displayName, binding.countryDropDown.selectedItem.toString())
+ }
+
+ @Test
+ fun `GIVEN an existing address WHEN the save button is clicked THEN proper metrics are recorded`() = runBlocking {
+ assertNull(Addresses.updated.testGetValue())
+
+ val addressEditorView = spyk(AddressEditorView(binding, interactor, address = address))
+ addressEditorView.bind()
+
+ binding.saveButton.performClick()
+
+ assertNotNull(Addresses.updated.testGetValue())
+ }
+
+ @Test
+ fun `GIVEN a new address WHEN the save button is clicked THEN proper metrics are recorded`() = runBlocking {
+ assertNull(Addresses.saved.testGetValue())
+
+ val addressEditorView = spyk(AddressEditorView(binding, interactor))
+ addressEditorView.bind()
+
+ binding.saveButton.performClick()
+
+ assertNotNull(Addresses.saved.testGetValue())
+ }
+
+ private fun generateAddress(country: String = "US", addressLevel1: String = "Oregon") = Address(
+ guid = "123",
+ name = "Name",
+ organization = "Organization",
+ streetAddress = "Street",
+ addressLevel3 = "Suburb",
+ addressLevel2 = "City",
+ addressLevel1 = addressLevel1,
+ postalCode = "PostalCode",
+ country = country,
+ tel = "Telephone",
+ email = "email@mozilla.com",
+ timeCreated = 0L,
+ timeLastUsed = 1L,
+ timeLastModified = 1L,
+ timesUsed = 2L,
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/controller/DefaultAddressEditorControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/controller/DefaultAddressEditorControllerTest.kt
new file mode 100644
index 0000000000..887dfad34f
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/controller/DefaultAddressEditorControllerTest.kt
@@ -0,0 +1,89 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.address.controller
+
+import androidx.navigation.NavController
+import io.mockk.coVerify
+import io.mockk.coVerifySequence
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.concept.storage.Address
+import mozilla.components.concept.storage.UpdatableAddressFields
+import mozilla.components.service.sync.autofill.AutofillCreditCardsAddressesStorage
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class DefaultAddressEditorControllerTest {
+
+ private val storage: AutofillCreditCardsAddressesStorage = mockk(relaxed = true)
+ private val navController: NavController = mockk(relaxed = true)
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ private lateinit var controller: DefaultAddressEditorController
+
+ @Before
+ fun setup() {
+ controller = spyk(
+ DefaultAddressEditorController(
+ storage = storage,
+ lifecycleScope = coroutinesTestRule.scope,
+ navController = navController,
+ ),
+ )
+ }
+
+ @Test
+ fun `WHEN cancel button is clicked THEN pop the NavController back stack`() {
+ controller.handleCancelButtonClicked()
+
+ verify {
+ navController.popBackStack()
+ }
+ }
+
+ @Test
+ fun `GIVEN a new address record WHEN save address is called THEN save the new address record to storage`() = runTestOnMain {
+ val addressFields = UpdatableAddressFields(
+ name = "John Smith",
+ organization = "Mozilla",
+ streetAddress = "123 Sesame Street",
+ addressLevel3 = "",
+ addressLevel2 = "",
+ addressLevel1 = "",
+ postalCode = "90210",
+ country = "US",
+ tel = "+1 519 555-5555",
+ email = "foo@bar.com",
+ )
+
+ controller.handleSaveAddress(addressFields)
+
+ coVerify {
+ storage.addAddress(addressFields)
+ navController.popBackStack()
+ }
+ }
+
+ @Test
+ fun `GIVEN an existing address record WHEN save address is called THEN update the address record to storage`() = runTestOnMain {
+ val address: Address = mockk()
+ val addressFields: UpdatableAddressFields = mockk()
+ every { address.guid } returns "123"
+
+ controller.handleUpdateAddress(address.guid, addressFields)
+
+ coVerifySequence {
+ storage.updateAddress("123", addressFields)
+ navController.popBackStack()
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/controller/DefaultAddressManagementControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/controller/DefaultAddressManagementControllerTest.kt
new file mode 100644
index 0000000000..70e55f8bcd
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/controller/DefaultAddressManagementControllerTest.kt
@@ -0,0 +1,58 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.address.controller
+
+import androidx.navigation.NavController
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.concept.storage.Address
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.settings.address.AddressManagementFragmentDirections
+
+class DefaultAddressManagementControllerTest {
+
+ private val navController: NavController = mockk(relaxed = true)
+
+ private lateinit var controller: AddressManagementController
+
+ @Before
+ fun setup() {
+ controller = spyk(
+ DefaultAddressManagementController(
+ navController = navController,
+ ),
+ )
+ }
+
+ @Test
+ fun `WHEN an address is selected THEN navigate to the address editor`() {
+ val address: Address = mockk(relaxed = true)
+
+ controller.handleAddressClicked(address)
+
+ verify {
+ navController.navigate(
+ AddressManagementFragmentDirections
+ .actionAddressManagementFragmentToAddressEditorFragment(
+ address = address,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN the add address button is clicked THEN navigate to the address editor`() {
+ controller.handleAddAddressButtonClicked()
+
+ verify {
+ navController.navigate(
+ AddressManagementFragmentDirections
+ .actionAddressManagementFragmentToAddressEditorFragment(),
+ )
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/ext/AddressTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/ext/AddressTest.kt
new file mode 100644
index 0000000000..a41d0c707d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/ext/AddressTest.kt
@@ -0,0 +1,102 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.address.ext
+
+import mozilla.components.concept.storage.Address
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class AddressTest {
+
+ @Test
+ fun `WHEN all properties are present THEN all properties present in description`() {
+ val addr = generateAddress()
+
+ val description = addr.getAddressLabel()
+
+ val expected = "${addr.streetAddress}, ${addr.addressLevel3}, ${addr.addressLevel2}, " +
+ "${addr.organization}, ${addr.addressLevel1}, ${addr.country}, " +
+ "${addr.postalCode}, ${addr.tel}, ${addr.email}"
+
+ assertEquals(expected, description)
+ }
+
+ @Test
+ fun `WHEN any properties are missing THEN description includes only present`() {
+ val addr = generateAddress(
+ addressLevel3 = "",
+ organization = "",
+ email = "",
+ )
+
+ val description = addr.getAddressLabel()
+
+ val expected = "${addr.streetAddress}, ${addr.addressLevel2}, ${addr.addressLevel1}, " +
+ "${addr.country}, ${addr.postalCode}, ${addr.tel}"
+ assertEquals(expected, description)
+ }
+
+ @Test
+ fun `WHEN everything is missing THEN description is empty`() {
+ val addr = generateAddress(
+ name = "",
+ organization = "",
+ streetAddress = "",
+ addressLevel3 = "",
+ addressLevel2 = "",
+ addressLevel1 = "",
+ postalCode = "",
+ country = "",
+ tel = "",
+ email = "",
+ )
+
+ val description = addr.getAddressLabel()
+
+ assertEquals("", description)
+ }
+
+ @Test
+ fun `GIVEN multiline street address THEN joined as single line`() {
+ val streetAddress = """
+ line1
+ line2
+ line3
+ """.trimIndent()
+
+ val result = streetAddress.toOneLineAddress()
+
+ assertEquals("line1 line2 line3", result)
+ }
+
+ private fun generateAddress(
+ name: String = "Firefox The Browser",
+ organization: String = "Mozilla",
+ streetAddress: String = "street",
+ addressLevel3: String = "3",
+ addressLevel2: String = "2",
+ addressLevel1: String = "1",
+ postalCode: String = "code",
+ country: String = "country",
+ tel: String = "tel",
+ email: String = "email",
+ ) = Address(
+ guid = "",
+ name = name,
+ organization = organization,
+ streetAddress = streetAddress,
+ addressLevel3 = addressLevel3,
+ addressLevel2 = addressLevel2,
+ addressLevel1 = addressLevel1,
+ postalCode = postalCode,
+ country = country,
+ tel = tel,
+ email = email,
+ timeCreated = 1,
+ timeLastUsed = 1,
+ timeLastModified = 1,
+ timesUsed = 1,
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/interactor/DefaultAddressEditorInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/interactor/DefaultAddressEditorInteractorTest.kt
new file mode 100644
index 0000000000..515d97d3ce
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/interactor/DefaultAddressEditorInteractorTest.kt
@@ -0,0 +1,50 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.address.interactor
+
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.concept.storage.UpdatableAddressFields
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.settings.address.controller.AddressEditorController
+
+class DefaultAddressEditorInteractorTest {
+
+ private val controller: AddressEditorController = mockk(relaxed = true)
+
+ private lateinit var interactor: AddressEditorInteractor
+
+ @Before
+ fun setup() {
+ interactor = DefaultAddressEditorInteractor(controller)
+ }
+
+ @Test
+ fun `WHEN cancel button is clicked THEN forward to controller handler`() {
+ interactor.onCancelButtonClicked()
+ verify { controller.handleCancelButtonClicked() }
+ }
+
+ @Test
+ fun `WHEN save button is clicked THEN forward to controller handler`() {
+ val addressFields = UpdatableAddressFields(
+ name = "John Smith",
+ organization = "Mozilla",
+ streetAddress = "123 Sesame Street",
+ addressLevel3 = "",
+ addressLevel2 = "",
+ addressLevel1 = "",
+ postalCode = "90210",
+ country = "US",
+ tel = "+1 519 555-5555",
+ email = "foo@bar.com",
+ )
+
+ interactor.onSaveAddress(addressFields)
+
+ verify { controller.handleSaveAddress(addressFields) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/interactor/DefaultAddressManagementInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/interactor/DefaultAddressManagementInteractorTest.kt
new file mode 100644
index 0000000000..9a02c09aaa
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/address/interactor/DefaultAddressManagementInteractorTest.kt
@@ -0,0 +1,40 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.address.interactor
+
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.concept.storage.Address
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.settings.address.controller.AddressManagementController
+
+class DefaultAddressManagementInteractorTest {
+
+ private val controller: AddressManagementController = mockk(relaxed = true)
+
+ private lateinit var interactor: AddressManagementInteractor
+
+ @Before
+ fun setup() {
+ interactor = DefaultAddressManagementInteractor(controller)
+ }
+
+ @Test
+ fun `WHEN an address is selected THEN forward to controller handler`() {
+ val address: Address = mockk(relaxed = true)
+
+ interactor.onSelectAddress(address)
+
+ verify { controller.handleAddressClicked(address) }
+ }
+
+ @Test
+ fun `WHEN add address button is clicked THEN forward to controller handler`() {
+ interactor.onAddAddressButtonClick()
+
+ verify { controller.handleAddAddressButtonClicked() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/advanced/BaseLocaleViewHolderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/advanced/BaseLocaleViewHolderTest.kt
new file mode 100644
index 0000000000..e1ff92b415
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/advanced/BaseLocaleViewHolderTest.kt
@@ -0,0 +1,59 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.advanced
+
+import android.content.Context
+import android.view.View
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import mozilla.components.support.locale.LocaleManager
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import java.util.Locale
+
+class BaseLocaleViewHolderTest {
+
+ private val selectedLocale = Locale("en", "UK")
+ private val view: View = mockk()
+ private val context: Context = mockk()
+
+ private val localeViewHolder = object : BaseLocaleViewHolder(view, selectedLocale) {
+ override fun bind(locale: Locale) = Unit
+ }
+
+ @Before
+ fun setup() {
+ mockkObject(LocaleManager)
+ every { view.context } returns context
+ }
+
+ @Test
+ fun `verify other locale checker returns false`() {
+ every { LocaleManager.getCurrentLocale(context) } returns mockk()
+ val otherLocale = mockk<Locale>()
+
+ assertFalse(localeViewHolder.isCurrentLocaleSelected(otherLocale, isDefault = true))
+ assertFalse(localeViewHolder.isCurrentLocaleSelected(otherLocale, isDefault = false))
+ }
+
+ @Test
+ fun `verify selected locale checker returns true`() {
+ every { LocaleManager.getCurrentLocale(context) } returns mockk()
+
+ assertFalse(localeViewHolder.isCurrentLocaleSelected(selectedLocale, isDefault = true))
+ assertTrue(localeViewHolder.isCurrentLocaleSelected(selectedLocale, isDefault = false))
+ }
+
+ @Test
+ fun `verify default locale checker returns true`() {
+ every { LocaleManager.getCurrentLocale(context) } returns null
+
+ assertTrue(localeViewHolder.isCurrentLocaleSelected(selectedLocale, isDefault = true))
+ assertFalse(localeViewHolder.isCurrentLocaleSelected(selectedLocale, isDefault = false))
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleManagerExtensionTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleManagerExtensionTest.kt
new file mode 100644
index 0000000000..7f4381eb8e
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleManagerExtensionTest.kt
@@ -0,0 +1,86 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.advanced
+
+import android.content.Context
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import mozilla.components.support.locale.LocaleManager
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.BuildConfig
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.util.Locale
+
+@RunWith(FenixRobolectricTestRunner::class)
+class LocaleManagerExtensionTest {
+
+ private lateinit var context: Context
+
+ @Before
+ fun setup() {
+ context = mockk()
+ mockkObject(LocaleManager)
+ }
+
+ @Test
+ @Config(qualifiers = "en-rUS")
+ fun `build supported locale list`() {
+ val list = LocaleManager.getSupportedLocales()
+
+ // Expect all supported locales + 'follow default option'
+ val expectedSize = BuildConfig.SUPPORTED_LOCALE_ARRAY.size + 1
+
+ assertEquals(expectedSize, list.size)
+ assertTrue(list.isNotEmpty())
+ }
+
+ @Test
+ @Config(qualifiers = "en-rUS")
+ fun `default locale selected`() {
+ every { LocaleManager.getCurrentLocale(context) } returns null
+
+ assertTrue(LocaleManager.isDefaultLocaleSelected(context))
+ }
+
+ @Test
+ @Config(qualifiers = "en-rUS")
+ fun `custom locale selected`() {
+ val selectedLocale = Locale("en", "UK")
+ every { LocaleManager.getCurrentLocale(context) } returns selectedLocale
+
+ assertFalse(LocaleManager.isDefaultLocaleSelected(context))
+ }
+
+ @Test
+ @Config(qualifiers = "en-rUS")
+ fun `match current stored locale string with a Locale from our list`() {
+ val otherLocale = Locale("fr")
+ val selectedLocale = Locale("en", "UK")
+ val localeList = listOf(otherLocale, selectedLocale)
+
+ every { LocaleManager.getCurrentLocale(context) } returns selectedLocale
+
+ assertEquals(selectedLocale, LocaleManager.getSelectedLocale(context, localeList))
+ }
+
+ @Test
+ @Config(qualifiers = "en-rUS")
+ fun `match null stored locale with the default Locale from our list`() {
+ val firstLocale = Locale("fr")
+ val secondLocale = Locale("en", "UK")
+ val localeList = listOf(firstLocale, secondLocale)
+
+ every { LocaleManager.getCurrentLocale(context) } returns null
+
+ assertEquals("en-US", LocaleManager.getSelectedLocale(context, localeList).toLanguageTag())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleSettingsControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleSettingsControllerTest.kt
new file mode 100644
index 0000000000..bdc3f8a7fc
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleSettingsControllerTest.kt
@@ -0,0 +1,179 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.advanced
+
+import android.app.Activity
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.mockkStatic
+import io.mockk.spyk
+import io.mockk.verify
+import io.mockk.verifyAll
+import mozilla.components.browser.state.action.SearchAction
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.support.locale.LocaleManager
+import mozilla.components.support.locale.LocaleUseCases
+import org.junit.Before
+import org.junit.Test
+import java.util.Locale
+
+class LocaleSettingsControllerTest {
+
+ private val activity = mockk<Activity>(relaxed = true)
+ private val localeSettingsStore: LocaleSettingsStore = mockk(relaxed = true)
+ private val browserStore: BrowserStore = mockk(relaxed = true)
+ private val localeUseCases: LocaleUseCases = mockk(relaxed = true)
+ private val mockState = LocaleSettingsState(mockk(), mockk(), mockk())
+
+ private lateinit var controller: DefaultLocaleSettingsController
+
+ @Before
+ fun setup() {
+ controller = spyk(
+ DefaultLocaleSettingsController(
+ activity,
+ localeSettingsStore,
+ browserStore,
+ localeUseCases,
+ ),
+ )
+
+ mockkObject(LocaleManager)
+ mockkStatic("org.mozilla.fenix.settings.advanced.LocaleManagerExtensionKt")
+ }
+
+ @Test
+ fun `don't set locale if same locale is chosen`() {
+ val selectedLocale = Locale("en", "UK")
+ every { localeSettingsStore.state } returns mockState.copy(selectedLocale = selectedLocale)
+ every { LocaleManager.getCurrentLocale(activity) } returns mockk()
+
+ controller.handleLocaleSelected(selectedLocale)
+
+ verifyAll(inverse = true) {
+ localeSettingsStore.dispatch(LocaleSettingsAction.Select(selectedLocale))
+ browserStore.dispatch(SearchAction.RefreshSearchEnginesAction)
+ LocaleManager.setNewLocale(activity, locale = selectedLocale)
+ activity.recreate()
+ }
+ with(controller) {
+ verify(inverse = true) {
+ LocaleManager.updateBaseConfiguration(activity, selectedLocale)
+ }
+ }
+ }
+
+ @Test
+ fun `set a new locale from the list if other locale is chosen`() {
+ val selectedLocale = Locale("en", "UK")
+ val otherLocale: Locale = mockk()
+ every { localeUseCases.notifyLocaleChanged } returns mockk()
+ every { localeSettingsStore.state } returns mockState.copy(selectedLocale = otherLocale)
+ every { LocaleManager.setNewLocale(activity, localeUseCases, selectedLocale) } returns activity
+ with(controller) {
+ every { LocaleManager.updateBaseConfiguration(activity, selectedLocale) } just Runs
+ }
+
+ controller.handleLocaleSelected(selectedLocale)
+
+ verify { localeSettingsStore.dispatch(LocaleSettingsAction.Select(selectedLocale)) }
+ verify { browserStore.dispatch(SearchAction.RefreshSearchEnginesAction) }
+ verify { LocaleManager.setNewLocale(activity, localeUseCases, selectedLocale) }
+ verify { activity.recreate() }
+ verify {
+ @Suppress("DEPRECATION")
+ activity.overridePendingTransition(0, 0)
+ }
+
+ with(controller) {
+ verify { LocaleManager.updateBaseConfiguration(activity, selectedLocale) }
+ }
+ }
+
+ @Test
+ fun `set a new locale from the list if default locale is not selected`() {
+ val selectedLocale = Locale("en", "UK")
+ every { localeUseCases.notifyLocaleChanged } returns mockk()
+ every { localeSettingsStore.state } returns mockState.copy(selectedLocale = selectedLocale)
+ every { LocaleManager.getCurrentLocale(activity) } returns null
+ every { LocaleManager.setNewLocale(activity, localeUseCases, selectedLocale) } returns activity
+
+ with(controller) {
+ every { LocaleManager.updateBaseConfiguration(activity, selectedLocale) } just Runs
+ }
+
+ controller.handleLocaleSelected(selectedLocale)
+
+ verify { localeSettingsStore.dispatch(LocaleSettingsAction.Select(selectedLocale)) }
+ verify { browserStore.dispatch(SearchAction.RefreshSearchEnginesAction) }
+ verify { LocaleManager.setNewLocale(activity, localeUseCases, selectedLocale) }
+ verify { activity.recreate() }
+ verify {
+ @Suppress("DEPRECATION")
+ activity.overridePendingTransition(0, 0)
+ }
+
+ with(controller) {
+ verify { LocaleManager.updateBaseConfiguration(activity, selectedLocale) }
+ }
+ }
+
+ @Test
+ fun `don't set default locale if default locale is already chosen`() {
+ val selectedLocale = Locale("en", "UK")
+ every { localeSettingsStore.state } returns mockState.copy(localeList = listOf(selectedLocale))
+ every { LocaleManager.getCurrentLocale(activity) } returns null
+
+ controller.handleDefaultLocaleSelected()
+
+ verifyAll(inverse = true) {
+ localeSettingsStore.dispatch(LocaleSettingsAction.Select(selectedLocale))
+ browserStore.dispatch(SearchAction.RefreshSearchEnginesAction)
+ LocaleManager.resetToSystemDefault(activity, localeUseCases)
+ activity.recreate()
+ with(controller) {
+ LocaleManager.updateBaseConfiguration(activity, selectedLocale)
+ }
+ }
+ }
+
+ @Test
+ fun `set the default locale as the new locale`() {
+ val selectedLocale = Locale("en", "UK")
+ every { localeUseCases.notifyLocaleChanged } returns mockk()
+ every { localeSettingsStore.state } returns mockState.copy(localeList = listOf(selectedLocale))
+ every { LocaleManager.resetToSystemDefault(activity, localeUseCases) } just Runs
+ with(controller) {
+ every { LocaleManager.updateBaseConfiguration(activity, selectedLocale) } just Runs
+ }
+
+ controller.handleDefaultLocaleSelected()
+
+ verify { localeSettingsStore.dispatch(LocaleSettingsAction.Select(selectedLocale)) }
+ verify { browserStore.dispatch(SearchAction.RefreshSearchEnginesAction) }
+ verify { LocaleManager.resetToSystemDefault(activity, localeUseCases) }
+ verify { activity.recreate() }
+ verify {
+ @Suppress("DEPRECATION")
+ activity.overridePendingTransition(0, 0)
+ }
+
+ with(controller) {
+ verify { LocaleManager.updateBaseConfiguration(activity, selectedLocale) }
+ }
+ }
+
+ @Test
+ fun `handle search query typed`() {
+ val query = "Eng"
+
+ controller.handleSearchQueryTyped(query)
+
+ verify { localeSettingsStore.dispatch(LocaleSettingsAction.Search(query)) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleSettingsInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleSettingsInteractorTest.kt
new file mode 100644
index 0000000000..0eb75c9c73
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleSettingsInteractorTest.kt
@@ -0,0 +1,47 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.advanced
+
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+import java.util.Locale
+
+class LocaleSettingsInteractorTest {
+
+ private lateinit var interactor: LocaleSettingsInteractor
+ private val controller: LocaleSettingsController = mockk(relaxed = true)
+
+ @Before
+ fun setup() {
+ interactor = LocaleSettingsInteractor(controller)
+ }
+
+ @Test
+ fun `locale was selected from list`() {
+ val locale: Locale = mockk()
+
+ interactor.onLocaleSelected(locale)
+
+ verify { controller.handleLocaleSelected(locale) }
+ }
+
+ @Test
+ fun `default locale was selected from list`() {
+ interactor.onDefaultLocaleSelected()
+
+ verify { controller.handleDefaultLocaleSelected() }
+ }
+
+ @Test
+ fun `search query was typed`() {
+ val query = "Eng"
+
+ interactor.onSearchQueryTyped(query)
+
+ verify { controller.handleSearchQueryTyped(query) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleSettingsStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleSettingsStoreTest.kt
new file mode 100644
index 0000000000..664a44f07e
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleSettingsStoreTest.kt
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.advanced
+
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import java.util.Locale
+
+class LocaleSettingsStoreTest {
+
+ private lateinit var localeSettingsStore: LocaleSettingsStore
+ private val selectedLocale = Locale("en", "UK")
+ private val otherLocale = Locale("fr")
+
+ @Before
+ fun setup() {
+ val localeList = listOf(
+ Locale("fr"), // default
+ otherLocale,
+ selectedLocale,
+ )
+
+ localeSettingsStore =
+ LocaleSettingsStore(LocaleSettingsState(localeList, localeList, selectedLocale))
+ }
+
+ @Test
+ fun `change selected locale`() = runTest {
+ localeSettingsStore.dispatch(LocaleSettingsAction.Select(otherLocale)).join()
+
+ assertEquals(otherLocale, localeSettingsStore.state.selectedLocale)
+ }
+
+ @Test
+ fun `change selected list by search query`() = runTest {
+ localeSettingsStore.dispatch(LocaleSettingsAction.Search("Eng")).join()
+
+ assertEquals(2, (localeSettingsStore.state.searchedLocaleList as ArrayList).size)
+ assertEquals(selectedLocale, localeSettingsStore.state.searchedLocaleList[1])
+ }
+
+ @Test
+ fun `GIVEN search list is amended WHEN locale selected THEN reset search list`() = runTest {
+ localeSettingsStore.dispatch(LocaleSettingsAction.Search("Eng")).join()
+ assertEquals(2, (localeSettingsStore.state.searchedLocaleList as ArrayList).size)
+
+ localeSettingsStore.dispatch(LocaleSettingsAction.Search("fr")).join()
+ localeSettingsStore.dispatch(LocaleSettingsAction.Select(otherLocale)).join()
+
+ assertEquals(localeSettingsStore.state.localeList.size, localeSettingsStore.state.searchedLocaleList.size)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleViewHoldersTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleViewHoldersTest.kt
new file mode 100644
index 0000000000..622a785691
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleViewHoldersTest.kt
@@ -0,0 +1,110 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.advanced
+
+import android.view.LayoutInflater
+import android.view.View
+import androidx.core.view.isVisible
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.verify
+import mozilla.components.support.locale.LocaleManager
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.databinding.LocaleSettingsItemBinding
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import java.util.Locale
+
+@RunWith(FenixRobolectricTestRunner::class)
+class LocaleViewHoldersTest {
+
+ private val selectedLocale = Locale("en", "US")
+ private lateinit var view: View
+ private lateinit var interactor: LocaleSettingsViewInteractor
+ private lateinit var localeViewHolder: LocaleViewHolder
+ private lateinit var systemLocaleViewHolder: SystemLocaleViewHolder
+ private lateinit var localeSettingsItemBinding: LocaleSettingsItemBinding
+
+ @Before
+ fun setup() {
+ mockkObject(LocaleManager)
+ every { LocaleManager.getCurrentLocale(any()) } returns null
+
+ view = LayoutInflater.from(testContext)
+ .inflate(R.layout.locale_settings_item, null)
+
+ localeSettingsItemBinding = LocaleSettingsItemBinding.bind(view)
+ interactor = mockk()
+
+ localeViewHolder = LocaleViewHolder(view, selectedLocale, interactor)
+ systemLocaleViewHolder = SystemLocaleViewHolder(view, selectedLocale, interactor)
+ }
+
+ @Test
+ fun `bind LocaleViewHolder`() {
+ localeViewHolder.bind(selectedLocale)
+
+ assertEquals("English (United States)", localeSettingsItemBinding.localeTitleText.text)
+ assertEquals("English (United States)", localeSettingsItemBinding.localeSubtitleText.text)
+ assertFalse(localeSettingsItemBinding.localeSelectedIcon.isVisible)
+ }
+
+ @Test
+ fun `LocaleViewHolder calls interactor on click`() {
+ localeViewHolder.bind(selectedLocale)
+
+ every { interactor.onLocaleSelected(selectedLocale) } just Runs
+ view.performClick()
+ verify { interactor.onLocaleSelected(selectedLocale) }
+ }
+
+ // Note that after we can run tests on SDK 30 the result of the locale.getDisplayName(locale) could differ and this test will fail
+ @Test
+ fun `GIVEN a locale is not properly identified in Android WHEN we bind locale THEN the title and subtitle are set from locale maps`() {
+ val otherLocale = Locale("vec")
+
+ localeViewHolder.bind(otherLocale)
+
+ assertEquals("Vèneto", localeSettingsItemBinding.localeTitleText.text)
+ assertEquals("Venetian", localeSettingsItemBinding.localeSubtitleText.text)
+ }
+
+ @Test
+ fun `GIVEN a locale is not properly identified in Android and it is not mapped WHEN we bind locale THEN the text is the capitalised code`() {
+ val otherLocale = Locale("yyy")
+
+ localeViewHolder.bind(otherLocale)
+
+ assertEquals("Yyy", localeSettingsItemBinding.localeTitleText.text)
+ assertEquals("Yyy", localeSettingsItemBinding.localeSubtitleText.text)
+ }
+
+ @Test
+ fun `bind SystemLocaleViewHolder`() {
+ systemLocaleViewHolder.bind(selectedLocale)
+
+ assertEquals("Follow device language", localeSettingsItemBinding.localeTitleText.text)
+ assertEquals("English (United States)", localeSettingsItemBinding.localeSubtitleText.text)
+ assertTrue(localeSettingsItemBinding.localeSelectedIcon.isVisible)
+ }
+
+ @Test
+ fun `SystemLocaleViewHolder calls interactor on click`() {
+ systemLocaleViewHolder.bind(selectedLocale)
+
+ every { interactor.onDefaultLocaleSelected() } just Runs
+ view.performClick()
+ verify { interactor.onDefaultLocaleSelected() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/autofill/AutofillFragmentStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/autofill/AutofillFragmentStoreTest.kt
new file mode 100644
index 0000000000..e0eff7def4
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/autofill/AutofillFragmentStoreTest.kt
@@ -0,0 +1,49 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.autofill
+
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import mozilla.components.concept.storage.Address
+import mozilla.components.concept.storage.CreditCard
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class AutofillFragmentStoreTest {
+
+ private lateinit var state: AutofillFragmentState
+ private lateinit var store: AutofillFragmentStore
+
+ @Before
+ fun setup() {
+ state = AutofillFragmentState()
+ store = AutofillFragmentStore(state)
+ }
+
+ @Test
+ fun testUpdateCreditCards() = runTest {
+ assertTrue(store.state.isLoading)
+
+ val creditCards: List<CreditCard> = listOf(mockk(), mockk())
+ store.dispatch(AutofillAction.UpdateCreditCards(creditCards)).join()
+
+ assertEquals(creditCards, store.state.creditCards)
+ assertFalse(store.state.isLoading)
+ }
+
+ @Test
+ fun `GIVEN a list of addresses WHEN update addresses action is dispatched THEN addresses state is updated`() = runTest {
+ assertTrue(store.state.isLoading)
+
+ val addresses: List<Address> = listOf(mockk(), mockk())
+ store.dispatch(AutofillAction.UpdateAddresses(addresses)).join()
+
+ assertEquals(addresses, store.state.addresses)
+ assertFalse(store.state.isLoading)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/autofill/AutofillSettingFragmentTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/autofill/AutofillSettingFragmentTest.kt
new file mode 100644
index 0000000000..261c71a480
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/autofill/AutofillSettingFragmentTest.kt
@@ -0,0 +1,227 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.autofill
+
+import androidx.fragment.app.FragmentActivity
+import androidx.navigation.NavController
+import androidx.preference.Preference
+import androidx.preference.SwitchPreference
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.test.advanceUntilIdle
+import mozilla.components.concept.storage.Address
+import mozilla.components.concept.storage.CreditCard
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.getPreferenceKey
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.robolectric.Robolectric
+
+@RunWith(FenixRobolectricTestRunner::class)
+class AutofillSettingFragmentTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private lateinit var autofillSettingFragment: AutofillSettingFragment
+ private val navController: NavController = mockk(relaxed = true)
+
+ @Before
+ fun setUp() = runTestOnMain {
+ every { testContext.components.settings } returns mockk(relaxed = true)
+ every { testContext.components.core } returns mockk(relaxed = true)
+
+ every { testContext.components.settings.addressFeature } returns true
+ every { testContext.components.settings.shouldAutofillCreditCardDetails } returns true
+ every { testContext.components.settings.shouldAutofillAddressDetails } returns true
+
+ autofillSettingFragment = AutofillSettingFragment()
+
+ val activity = Robolectric.buildActivity(FragmentActivity::class.java).create().get()
+
+ activity.supportFragmentManager.beginTransaction()
+ .add(autofillSettingFragment, "CreditCardsSettingFragmentTest")
+ .commitNow()
+ advanceUntilIdle()
+ }
+
+ @Test
+ fun `GIVEN the list of credit cards is not empty, WHEN fragment is displayed THEN the manage credit cards pref is 'Manage cards'`() = runTestOnMain {
+ val preferenceTitle =
+ testContext.getString(R.string.preferences_credit_cards_manage_saved_cards_2)
+ val manageCardsPreference = autofillSettingFragment.findPreference<Preference>(
+ autofillSettingFragment.getPreferenceKey(R.string.pref_key_credit_cards_manage_cards),
+ )
+
+ val creditCards: List<CreditCard> = listOf(mockk(), mockk())
+
+ val state = AutofillFragmentState(creditCards = creditCards)
+ val store = AutofillFragmentStore(state)
+
+ autofillSettingFragment.updateCardManagementPreference(
+ store.state.creditCards.isNotEmpty(),
+ navController,
+ )
+
+ assertNull(manageCardsPreference?.icon)
+ assertEquals(preferenceTitle, manageCardsPreference?.title)
+ }
+
+ @Test
+ fun `GIVEN the list of credit cards is empty, WHEN fragment is displayed THEN the manage credit cards pref is 'Add card'`() = runTestOnMain {
+ val preferenceTitle =
+ testContext.getString(R.string.preferences_credit_cards_add_credit_card_2)
+ val manageCardsPreference = autofillSettingFragment.findPreference<Preference>(
+ autofillSettingFragment.getPreferenceKey(R.string.pref_key_credit_cards_manage_cards),
+ )
+
+ val directions =
+ AutofillSettingFragmentDirections
+ .actionAutofillSettingFragmentToCreditCardEditorFragment()
+
+ val state = AutofillFragmentState()
+ val store = AutofillFragmentStore(state)
+
+ autofillSettingFragment.updateCardManagementPreference(
+ store.state.creditCards.isNotEmpty(),
+ navController,
+ )
+
+ assertNotNull(manageCardsPreference?.icon)
+ assertEquals(preferenceTitle, manageCardsPreference?.title)
+
+ manageCardsPreference?.performClick()
+
+ verify { navController.navigate(directions) }
+ }
+
+ @Test
+ fun `GIVEN the list of addresses is not empty WHEN fragment is displayed THEN the manage addresses preference label is 'Manage addresses'`() = runTestOnMain {
+ val preferenceTitle =
+ testContext.getString(R.string.preferences_addresses_manage_addresses)
+ val manageAddressesPreference = autofillSettingFragment.findPreference<Preference>(
+ autofillSettingFragment.getPreferenceKey(R.string.pref_key_addresses_manage_addresses),
+ )
+
+ val addresses: List<Address> = listOf(mockk(), mockk())
+
+ val state = AutofillFragmentState(addresses = addresses)
+ val store = AutofillFragmentStore(state)
+
+ autofillSettingFragment.updateAddressPreference(
+ store.state.addresses.isNotEmpty(),
+ navController,
+ )
+
+ assertNull(manageAddressesPreference?.icon)
+ assertEquals(preferenceTitle, manageAddressesPreference?.title)
+
+ manageAddressesPreference?.performClick()
+
+ verify {
+ navController.navigate(
+ AutofillSettingFragmentDirections
+ .actionAutofillSettingFragmentToAddressManagementFragment(),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN the list of addresses is empty WHEN fragment is displayed THEN the manage addresses preference label is 'Add address'`() = runTestOnMain {
+ val preferenceTitle =
+ testContext.getString(R.string.preferences_addresses_add_address)
+ val manageAddressesPreference = autofillSettingFragment.findPreference<Preference>(
+ autofillSettingFragment.getPreferenceKey(R.string.pref_key_addresses_manage_addresses),
+ )
+
+ val state = AutofillFragmentState()
+ val store = AutofillFragmentStore(state)
+
+ autofillSettingFragment.updateAddressPreference(
+ store.state.addresses.isNotEmpty(),
+ navController,
+ )
+
+ assertNotNull(manageAddressesPreference?.icon)
+ assertEquals(preferenceTitle, manageAddressesPreference?.title)
+
+ manageAddressesPreference?.performClick()
+
+ verify {
+ navController.navigate(
+ AutofillSettingFragmentDirections
+ .actionAutofillSettingFragmentToAddressEditorFragment(),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN the autofill addresses feature is enabled THEN the addresses switch preference is checked`() = runTestOnMain {
+ every { testContext.components.settings.shouldAutofillAddressDetails } returns true
+
+ val autofillAddressesPreference = autofillSettingFragment.findPreference<SwitchPreference>(
+ autofillSettingFragment.getPreferenceKey(R.string.pref_key_addresses_save_and_autofill_addresses),
+ )
+
+ autofillSettingFragment.updateSaveAndAutofillAddressesSwitch()
+
+ assertNotNull(autofillAddressesPreference)
+ assertTrue(autofillAddressesPreference?.isChecked!!)
+ }
+
+ @Test
+ fun `GIVEN the autofill addresses feature is disabled THEN the addresses switch preference is NOT checked`() = runTestOnMain {
+ every { testContext.components.settings.shouldAutofillAddressDetails } returns false
+
+ val autofillAddressesPreference = autofillSettingFragment.findPreference<SwitchPreference>(
+ autofillSettingFragment.getPreferenceKey(R.string.pref_key_addresses_save_and_autofill_addresses),
+ )
+
+ autofillSettingFragment.updateSaveAndAutofillAddressesSwitch()
+
+ assertNotNull(autofillAddressesPreference)
+ assertFalse(autofillAddressesPreference?.isChecked!!)
+ }
+
+ @Test
+ fun `GIVEN the autofill cards feature is enabled THEN cards the switch preference is checked`() = runTestOnMain {
+ every { testContext.components.settings.shouldAutofillCreditCardDetails } returns true
+
+ val autofillCardsPreference = autofillSettingFragment.findPreference<SwitchPreference>(
+ autofillSettingFragment.getPreferenceKey(R.string.pref_key_credit_cards_save_and_autofill_cards),
+ )
+
+ autofillSettingFragment.updateSaveAndAutofillCardsSwitch()
+
+ assertNotNull(autofillCardsPreference)
+ assertTrue(autofillCardsPreference?.isChecked!!)
+ }
+
+ @Test
+ fun `GIVEN the autofill cards feature is disabled THEN the cards switch preference is NOT checked`() = runTestOnMain {
+ every { testContext.components.settings.shouldAutofillCreditCardDetails } returns false
+
+ val autofillCardsPreference = autofillSettingFragment.findPreference<SwitchPreference>(
+ autofillSettingFragment.getPreferenceKey(R.string.pref_key_credit_cards_save_and_autofill_cards),
+ )
+
+ autofillSettingFragment.updateSaveAndAutofillCardsSwitch()
+
+ assertNotNull(autofillCardsPreference)
+ assertFalse(autofillCardsPreference?.isChecked!!)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/biometric/BiometricPromptFeatureTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/biometric/BiometricPromptFeatureTest.kt
new file mode 100644
index 0000000000..66a081aacb
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/biometric/BiometricPromptFeatureTest.kt
@@ -0,0 +1,126 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.biometric
+
+import android.os.Build.VERSION_CODES.M
+import android.os.Build.VERSION_CODES.N
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
+import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
+import androidx.biometric.BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE
+import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS
+import androidx.biometric.BiometricPrompt
+import androidx.fragment.app.Fragment
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.slot
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import mozilla.components.support.test.robolectric.createAddedTestFragment
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.biometric.ext.isEnrolled
+import org.mozilla.fenix.settings.biometric.ext.isHardwareAvailable
+import org.robolectric.annotation.Config
+
+@RunWith(FenixRobolectricTestRunner::class)
+class BiometricPromptFeatureTest {
+
+ lateinit var fragment: Fragment
+
+ @Before
+ fun setup() {
+ fragment = createAddedTestFragment { Fragment() }
+ }
+
+ @Config(sdk = [N])
+ @Test
+ fun `canUseFeature checks for SDK compatible`() {
+ assertFalse(BiometricPromptFeature.canUseFeature(testContext))
+ }
+
+ @Config(sdk = [M])
+ @Test
+ fun `canUseFeature checks for hardware capabilities`() {
+ mockkStatic(BiometricManager::class)
+ val manager: BiometricManager = mockk()
+ every { BiometricManager.from(any()) } returns manager
+ every { manager.canAuthenticate(any()) } returns BIOMETRIC_SUCCESS
+
+ assertTrue(BiometricPromptFeature.canUseFeature(testContext))
+
+ every { manager.canAuthenticate(any()) } returns BIOMETRIC_ERROR_HW_UNAVAILABLE
+
+ assertFalse(BiometricPromptFeature.canUseFeature(testContext))
+
+ verify { manager.isEnrolled() }
+ verify { manager.isHardwareAvailable() }
+
+ // cleanup
+ unmockkStatic(BiometricManager::class)
+ }
+
+ @Test
+ fun `prompt is created and destroyed on start and stop`() {
+ val feature = BiometricPromptFeature(testContext, fragment, {}, {})
+
+ assertNull(feature.biometricPrompt)
+
+ feature.start()
+
+ assertNotNull(feature.biometricPrompt)
+
+ feature.stop()
+
+ assertNull(feature.biometricPrompt)
+ }
+
+ @Test
+ fun `requestAuthentication invokes biometric prompt`() {
+ val feature = BiometricPromptFeature(testContext, fragment, {}, {})
+ val prompt: BiometricPrompt = mockk(relaxed = true)
+ val promptInfo = slot<BiometricPrompt.PromptInfo>()
+
+ feature.biometricPrompt = prompt
+
+ feature.requestAuthentication("test")
+
+ verify { prompt.authenticate(capture(promptInfo)) }
+ assertEquals(BIOMETRIC_WEAK or DEVICE_CREDENTIAL, promptInfo.captured.allowedAuthenticators)
+ assertEquals("test", promptInfo.captured.title)
+ }
+
+ @Test
+ fun `promptCallback fires feature callbacks`() {
+ val authSuccess: () -> Unit = mockk(relaxed = true)
+ val authFailure: () -> Unit = mockk(relaxed = true)
+ val feature = BiometricPromptFeature(testContext, fragment, authFailure, authSuccess)
+ val callback = feature.PromptCallback()
+ val prompt = BiometricPrompt(fragment, callback)
+
+ feature.biometricPrompt = prompt
+
+ callback.onAuthenticationError(0, "")
+
+ verify { authFailure.invoke() }
+
+ callback.onAuthenticationFailed()
+
+ verify { authFailure.invoke() }
+
+ callback.onAuthenticationSucceeded(mockk())
+
+ verify { authSuccess.invoke() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/biometric/ext/BiometricManagerKtTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/biometric/ext/BiometricManagerKtTest.kt
new file mode 100644
index 0000000000..df8c69c2d1
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/biometric/ext/BiometricManagerKtTest.kt
@@ -0,0 +1,52 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.biometric.ext
+
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE
+import androidx.biometric.BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE
+import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class BiometricManagerKtTest {
+
+ lateinit var manager: BiometricManager
+
+ @Before
+ fun setup() {
+ manager = mockk()
+ }
+
+ @Test
+ fun `isHardwareAvailable checks status`() {
+ every { manager.canAuthenticate(any()) }.answers { BIOMETRIC_ERROR_NO_HARDWARE }
+
+ assertFalse(manager.isHardwareAvailable())
+
+ every { manager.canAuthenticate(any()) }.answers { BIOMETRIC_ERROR_HW_UNAVAILABLE }
+
+ assertFalse(manager.isHardwareAvailable())
+
+ every { manager.canAuthenticate(any()) }.answers { BIOMETRIC_SUCCESS }
+
+ assertTrue(manager.isHardwareAvailable())
+ }
+
+ @Test
+ fun `isEnrolled checks status`() {
+ every { manager.canAuthenticate(any()) }.answers { BIOMETRIC_ERROR_NO_HARDWARE }
+
+ assertFalse(manager.isEnrolled())
+
+ every { manager.canAuthenticate(any()) }.answers { BIOMETRIC_SUCCESS }
+
+ assertTrue(manager.isEnrolled())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardEditorStateTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardEditorStateTest.kt
new file mode 100644
index 0000000000..f914f2a889
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardEditorStateTest.kt
@@ -0,0 +1,77 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.creditcards
+
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import mozilla.components.concept.storage.CreditCard
+import mozilla.components.concept.storage.CreditCardNumber
+import mozilla.components.service.sync.autofill.AutofillCreditCardsAddressesStorage
+import mozilla.components.service.sync.autofill.AutofillCrypto
+import mozilla.components.support.utils.CreditCardNetworkType
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.mozilla.fenix.settings.creditcards.CreditCardEditorFragment.Companion.NUMBER_OF_YEARS_TO_SHOW
+import java.util.Calendar
+
+class CreditCardEditorStateTest {
+
+ private val cardNumber = "4111111111111110"
+ private val creditCard = CreditCard(
+ guid = "id",
+ billingName = "Banana Apple",
+ encryptedCardNumber = CreditCardNumber.Encrypted(cardNumber),
+ cardNumberLast4 = "1110",
+ expiryMonth = 5,
+ expiryYear = 2030,
+ cardType = CreditCardNetworkType.AMEX.cardName,
+ timeCreated = 1L,
+ timeLastUsed = 1L,
+ timeLastModified = 1L,
+ timesUsed = 1L,
+ )
+
+ @Test
+ fun testToCreditCardEditorState() = runTest {
+ val storage: AutofillCreditCardsAddressesStorage = mockk(relaxed = true)
+ val crypto: AutofillCrypto = mockk(relaxed = true)
+
+ every { storage.getCreditCardCrypto() } returns crypto
+ every { crypto.decrypt(any(), any()) } returns CreditCardNumber.Plaintext(cardNumber)
+
+ val state = creditCard.toCreditCardEditorState(storage)
+ val startYear = creditCard.expiryYear.toInt()
+ val endYear = startYear + NUMBER_OF_YEARS_TO_SHOW
+
+ with(state) {
+ assertEquals(creditCard.guid, guid)
+ assertEquals(creditCard.billingName, billingName)
+ assertEquals(creditCard.encryptedCardNumber.number, cardNumber)
+ assertEquals(creditCard.expiryMonth.toInt(), expiryMonth)
+ assertEquals(Pair(startYear, endYear), expiryYears)
+ assertTrue(isEditing)
+ }
+ }
+
+ @Test
+ fun testGetInitialCreditCardEditorState() {
+ val state = getInitialCreditCardEditorState()
+ val calendar = Calendar.getInstance()
+ val startYear = calendar.get(Calendar.YEAR)
+ val endYear = startYear + NUMBER_OF_YEARS_TO_SHOW
+
+ with(state) {
+ assertEquals("", guid)
+ assertEquals("", billingName)
+ assertEquals("", cardNumber)
+ assertEquals(1, expiryMonth)
+ assertEquals(Pair(startYear, endYear), expiryYears)
+ assertFalse(isEditing)
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardEditorViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardEditorViewTest.kt
new file mode 100644
index 0000000000..6545d11d09
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardEditorViewTest.kt
@@ -0,0 +1,346 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.creditcards
+
+import android.view.LayoutInflater
+import android.view.View
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import mozilla.components.concept.storage.CreditCard
+import mozilla.components.concept.storage.CreditCardNumber
+import mozilla.components.concept.storage.NewCreditCardFields
+import mozilla.components.concept.storage.UpdatableCreditCardFields
+import mozilla.components.service.sync.autofill.AutofillCreditCardsAddressesStorage
+import mozilla.components.service.sync.autofill.AutofillCrypto
+import mozilla.components.support.ktx.android.content.getColorFromAttr
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.utils.CreditCardNetworkType
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.databinding.FragmentCreditCardEditorBinding
+import org.mozilla.fenix.ext.toEditable
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.creditcards.CreditCardEditorFragment.Companion.NUMBER_OF_YEARS_TO_SHOW
+import org.mozilla.fenix.settings.creditcards.interactor.CreditCardEditorInteractor
+import org.mozilla.fenix.settings.creditcards.view.CreditCardEditorView
+import java.util.Calendar
+
+@RunWith(FenixRobolectricTestRunner::class)
+class CreditCardEditorViewTest {
+
+ private lateinit var view: View
+ private lateinit var interactor: CreditCardEditorInteractor
+ private lateinit var creditCardEditorView: CreditCardEditorView
+ private lateinit var storage: AutofillCreditCardsAddressesStorage
+ private lateinit var crypto: AutofillCrypto
+ private lateinit var fragmentCreditCardEditorBinding: FragmentCreditCardEditorBinding
+
+ private val cardNumber = "4111111111111111"
+ private val creditCard = CreditCard(
+ guid = "id",
+ billingName = "Banana Apple",
+ encryptedCardNumber = CreditCardNumber.Encrypted(cardNumber),
+ cardNumberLast4 = "1111",
+ expiryMonth = 5,
+ expiryYear = 2030,
+ cardType = CreditCardNetworkType.VISA.cardName,
+ timeCreated = 1L,
+ timeLastUsed = 1L,
+ timeLastModified = 1L,
+ timesUsed = 1L,
+ )
+
+ @Before
+ fun setup() {
+ view = LayoutInflater.from(testContext).inflate(R.layout.fragment_credit_card_editor, null)
+ fragmentCreditCardEditorBinding = FragmentCreditCardEditorBinding.bind(view)
+ interactor = mockk(relaxed = true)
+ storage = mockk(relaxed = true)
+ crypto = mockk(relaxed = true)
+
+ every { storage.getCreditCardCrypto() } returns crypto
+ every { crypto.decrypt(any(), any()) } returns CreditCardNumber.Plaintext(cardNumber)
+
+ creditCardEditorView = spyk(CreditCardEditorView(fragmentCreditCardEditorBinding, interactor))
+ }
+
+ @Test
+ fun `GIVEN the initial credit card editor state THEN credit card form inputs are in initial state`() {
+ creditCardEditorView.bind(getInitialCreditCardEditorState())
+
+ val calendar = Calendar.getInstance()
+ val startYear = calendar.get(Calendar.YEAR)
+ val endYear = startYear + NUMBER_OF_YEARS_TO_SHOW - 1
+
+ assertEquals("", fragmentCreditCardEditorBinding.cardNumberInput.text.toString())
+ assertEquals("", fragmentCreditCardEditorBinding.nameOnCardInput.text.toString())
+
+ with(fragmentCreditCardEditorBinding.expiryMonthDropDown) {
+ assertEquals(12, count)
+ assertEquals("January (01)", selectedItem.toString())
+ assertEquals("December (12)", getItemAtPosition(count - 1).toString())
+ }
+
+ with(fragmentCreditCardEditorBinding.expiryYearDropDown) {
+ assertEquals(10, count)
+ assertEquals(startYear.toString(), selectedItem.toString())
+ assertEquals(endYear.toString(), getItemAtPosition(count - 1).toString())
+ }
+
+ assertEquals(View.GONE, fragmentCreditCardEditorBinding.deleteButton.visibility)
+ }
+
+ @Test
+ fun `GIVEN a credit card THEN credit card form inputs are displaying the provided credit card information`() = runTest {
+ creditCardEditorView.bind(creditCard.toCreditCardEditorState(storage))
+
+ assertEquals(cardNumber, fragmentCreditCardEditorBinding.cardNumberInput.text.toString())
+ assertEquals(creditCard.billingName, fragmentCreditCardEditorBinding.nameOnCardInput.text.toString())
+
+ with(fragmentCreditCardEditorBinding.expiryMonthDropDown) {
+ assertEquals(12, count)
+ assertEquals("May (05)", selectedItem.toString())
+ }
+
+ with(fragmentCreditCardEditorBinding.expiryYearDropDown) {
+ val endYear = creditCard.expiryYear + NUMBER_OF_YEARS_TO_SHOW - 1
+
+ assertEquals(10, count)
+ assertEquals(creditCard.expiryYear.toString(), selectedItem.toString())
+ assertEquals(endYear.toString(), getItemAtPosition(count - 1).toString())
+ }
+ }
+
+ @Test
+ fun `GIVEN a credit card WHEN the delete card button is clicked THEN interactor is called`() = runTest {
+ creditCardEditorView.bind(creditCard.toCreditCardEditorState(storage))
+
+ assertEquals(View.VISIBLE, fragmentCreditCardEditorBinding.deleteButton.visibility)
+
+ fragmentCreditCardEditorBinding.deleteButton.performClick()
+
+ verify { interactor.onDeleteCardButtonClicked(creditCard.guid) }
+ }
+
+ @Test
+ fun `WHEN the cancel button is clicked THEN interactor is called`() {
+ creditCardEditorView.bind(getInitialCreditCardEditorState())
+
+ fragmentCreditCardEditorBinding.cancelButton.performClick()
+
+ verify { interactor.onCancelButtonClicked() }
+ }
+
+ @Test
+ fun `GIVEN invalid credit card number WHEN the save button is clicked THEN interactor is not called`() {
+ creditCardEditorView.bind(getInitialCreditCardEditorState())
+
+ val calendar = Calendar.getInstance()
+
+ var billingName = "Banana Apple"
+ val cardNumber = "2221000000000000"
+ val expiryMonth = 5
+ val expiryYear = calendar.get(Calendar.YEAR)
+
+ fragmentCreditCardEditorBinding.cardNumberInput.text = cardNumber.toEditable()
+ fragmentCreditCardEditorBinding.nameOnCardInput.text = billingName.toEditable()
+ fragmentCreditCardEditorBinding.expiryMonthDropDown.setSelection(expiryMonth - 1)
+
+ fragmentCreditCardEditorBinding.saveButton.performClick()
+
+ verify {
+ creditCardEditorView.validateForm()
+ }
+
+ assertFalse(creditCardEditorView.validateForm())
+ assertNotNull(fragmentCreditCardEditorBinding.cardNumberLayout.error)
+ assertEquals(
+ fragmentCreditCardEditorBinding.cardNumberLayout.errorCurrentTextColors,
+ fragmentCreditCardEditorBinding.root.context.getColorFromAttr(R.attr.textWarning),
+ )
+
+ verify(exactly = 0) {
+ interactor.onSaveCreditCard(
+ NewCreditCardFields(
+ billingName = billingName,
+ plaintextCardNumber = CreditCardNumber.Plaintext(cardNumber),
+ cardNumberLast4 = "0000",
+ expiryMonth = expiryMonth.toLong(),
+ expiryYear = expiryYear.toLong(),
+ cardType = CreditCardNetworkType.MASTERCARD.cardName,
+ ),
+ )
+ }
+
+ billingName = ""
+ fragmentCreditCardEditorBinding.nameOnCardInput.text = billingName.toEditable()
+
+ fragmentCreditCardEditorBinding.saveButton.performClick()
+
+ assertFalse(creditCardEditorView.validateForm())
+ assertNotNull(fragmentCreditCardEditorBinding.cardNumberLayout.error)
+ assertEquals(
+ fragmentCreditCardEditorBinding.cardNumberLayout.errorCurrentTextColors,
+ fragmentCreditCardEditorBinding.root.context.getColorFromAttr(R.attr.textWarning),
+ )
+
+ verify(exactly = 0) {
+ interactor.onSaveCreditCard(
+ NewCreditCardFields(
+ billingName = billingName,
+ plaintextCardNumber = CreditCardNumber.Plaintext(cardNumber),
+ cardNumberLast4 = "0000",
+ expiryMonth = expiryMonth.toLong(),
+ expiryYear = expiryYear.toLong(),
+ cardType = CreditCardNetworkType.MASTERCARD.cardName,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN invalid credit card values WHEN valid values are entered and the save button is clicked THEN error messages are cleared`() {
+ creditCardEditorView.bind(getInitialCreditCardEditorState())
+
+ var billingName = ""
+ var cardNumber = "1234567891234567"
+ val expiryMonth = 5
+
+ fragmentCreditCardEditorBinding.cardNumberInput.text = cardNumber.toEditable()
+ fragmentCreditCardEditorBinding.nameOnCardInput.text = billingName.toEditable()
+ fragmentCreditCardEditorBinding.expiryMonthDropDown.setSelection(expiryMonth - 1)
+
+ fragmentCreditCardEditorBinding.saveButton.performClick()
+
+ verify {
+ creditCardEditorView.validateForm()
+ }
+
+ billingName = "Banana Apple"
+ cardNumber = "2720994326581252"
+ fragmentCreditCardEditorBinding.nameOnCardInput.text = billingName.toEditable()
+ fragmentCreditCardEditorBinding.cardNumberInput.text = cardNumber.toEditable()
+
+ fragmentCreditCardEditorBinding.saveButton.performClick()
+
+ verify {
+ creditCardEditorView.validateForm()
+ }
+
+ assertTrue(creditCardEditorView.validateForm())
+ assertNull(fragmentCreditCardEditorBinding.cardNumberLayout.error)
+ assertNull(fragmentCreditCardEditorBinding.nameOnCardLayout.error)
+ }
+
+ @Test
+ fun `GIVEN invalid name on card WHEN the save button is clicked THEN interactor is not called`() {
+ creditCardEditorView.bind(getInitialCreditCardEditorState())
+
+ val calendar = Calendar.getInstance()
+
+ val billingName = " "
+ val cardNumber = "2221000000000000"
+ val expiryMonth = 5
+ val expiryYear = calendar.get(Calendar.YEAR)
+
+ fragmentCreditCardEditorBinding.cardNumberInput.text = cardNumber.toEditable()
+ fragmentCreditCardEditorBinding.nameOnCardInput.text = billingName.toEditable()
+ fragmentCreditCardEditorBinding.expiryMonthDropDown.setSelection(expiryMonth - 1)
+
+ fragmentCreditCardEditorBinding.saveButton.performClick()
+
+ verify {
+ creditCardEditorView.validateForm()
+ }
+
+ assertFalse(creditCardEditorView.validateForm())
+ assertNotNull(fragmentCreditCardEditorBinding.nameOnCardLayout.error)
+ assertEquals(
+ fragmentCreditCardEditorBinding.nameOnCardLayout.errorCurrentTextColors,
+ fragmentCreditCardEditorBinding.root.context.getColorFromAttr(R.attr.textWarning),
+ )
+
+ verify(exactly = 0) {
+ interactor.onSaveCreditCard(
+ NewCreditCardFields(
+ billingName = billingName,
+ plaintextCardNumber = CreditCardNumber.Plaintext(cardNumber),
+ cardNumberLast4 = "0000",
+ expiryMonth = expiryMonth.toLong(),
+ expiryYear = expiryYear.toLong(),
+ cardType = CreditCardNetworkType.MASTERCARD.cardName,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN valid credit card number WHEN the save button is clicked THEN interactor is called`() {
+ creditCardEditorView.bind(getInitialCreditCardEditorState())
+
+ val calendar = Calendar.getInstance()
+
+ val billingName = "Banana Apple"
+ val cardNumber = "2720994326581252"
+ val expiryMonth = 5
+ val expiryYear = calendar.get(Calendar.YEAR)
+
+ fragmentCreditCardEditorBinding.cardNumberInput.text = cardNumber.toEditable()
+ fragmentCreditCardEditorBinding.nameOnCardInput.text = billingName.toEditable()
+ fragmentCreditCardEditorBinding.expiryMonthDropDown.setSelection(expiryMonth - 1)
+
+ fragmentCreditCardEditorBinding.saveButton.performClick()
+
+ verify {
+ creditCardEditorView.validateForm()
+ }
+
+ assertTrue(creditCardEditorView.validateForm())
+
+ verify {
+ interactor.onSaveCreditCard(
+ NewCreditCardFields(
+ billingName = billingName,
+ plaintextCardNumber = CreditCardNumber.Plaintext(cardNumber),
+ cardNumberLast4 = "1252",
+ expiryMonth = expiryMonth.toLong(),
+ expiryYear = expiryYear.toLong(),
+ cardType = CreditCardNetworkType.MASTERCARD.cardName,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN a valid credit card WHEN the save button is clicked THEN interactor is called`() = runTest {
+ creditCardEditorView.bind(creditCard.toCreditCardEditorState(storage))
+
+ fragmentCreditCardEditorBinding.saveButton.performClick()
+
+ verify {
+ interactor.onUpdateCreditCard(
+ guid = creditCard.guid,
+ creditCardFields = UpdatableCreditCardFields(
+ billingName = creditCard.billingName,
+ cardNumber = CreditCardNumber.Plaintext(cardNumber),
+ cardNumberLast4 = creditCard.cardNumberLast4,
+ expiryMonth = creditCard.expiryMonth,
+ expiryYear = creditCard.expiryYear,
+ cardType = creditCard.cardType,
+ ),
+ )
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardItemViewHolderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardItemViewHolderTest.kt
new file mode 100644
index 0000000000..2918a37095
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardItemViewHolderTest.kt
@@ -0,0 +1,67 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.creditcards
+
+import android.view.LayoutInflater
+import android.view.View
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.concept.storage.CreditCard
+import mozilla.components.concept.storage.CreditCardNumber
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.utils.CreditCardNetworkType
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.databinding.CreditCardListItemBinding
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.creditcards.interactor.CreditCardsManagementInteractor
+import org.mozilla.fenix.settings.creditcards.view.CreditCardItemViewHolder
+
+@RunWith(FenixRobolectricTestRunner::class)
+class CreditCardItemViewHolderTest {
+
+ private lateinit var view: View
+ private lateinit var interactor: CreditCardsManagementInteractor
+ private lateinit var binding: CreditCardListItemBinding
+
+ private val creditCard = CreditCard(
+ guid = "id",
+ billingName = "Banana Apple",
+ encryptedCardNumber = CreditCardNumber.Encrypted("4111111111111110"),
+ cardNumberLast4 = "1110",
+ expiryMonth = 1,
+ expiryYear = 2030,
+ cardType = CreditCardNetworkType.AMEX.cardName,
+ timeCreated = 1L,
+ timeLastUsed = 1L,
+ timeLastModified = 1L,
+ timesUsed = 1L,
+ )
+
+ @Before
+ fun setup() {
+ view = LayoutInflater.from(testContext).inflate(CreditCardItemViewHolder.LAYOUT_ID, null)
+ binding = CreditCardListItemBinding.bind(view)
+ interactor = mockk(relaxed = true)
+ }
+
+ @Test
+ fun `GIVEN a new credit card item on bind THEN set the card number and expiry date text`() {
+ CreditCardItemViewHolder(view, interactor).bind(creditCard)
+
+ assertEquals(creditCard.obfuscatedCardNumber, binding.creditCardNumber.text)
+ assertEquals("0${creditCard.expiryMonth}/${creditCard.expiryYear}", binding.expiryDate.text)
+ }
+
+ @Test
+ fun `WHEN a credit item is clicked THEN interactor is called`() {
+ CreditCardItemViewHolder(view, interactor).bind(creditCard)
+
+ view.performClick()
+ verify { interactor.onSelectCreditCard(creditCard) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardsAdapterTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardsAdapterTest.kt
new file mode 100644
index 0000000000..130bb62831
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardsAdapterTest.kt
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.creditcards
+
+import mozilla.components.concept.storage.CreditCard
+import mozilla.components.concept.storage.CreditCardNumber
+import mozilla.components.support.utils.CreditCardNetworkType
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.mozilla.fenix.settings.creditcards.view.CreditCardsAdapter
+
+class CreditCardsAdapterTest {
+
+ @Test
+ fun testDiffCallback() {
+ val creditCard1 = CreditCard(
+ guid = "id",
+ billingName = "Banana Apple",
+ encryptedCardNumber = CreditCardNumber.Encrypted("4111111111111110"),
+ cardNumberLast4 = "1110",
+ expiryMonth = 1,
+ expiryYear = 2030,
+ cardType = CreditCardNetworkType.AMEX.cardName,
+ timeCreated = 1L,
+ timeLastUsed = 1L,
+ timeLastModified = 1L,
+ timesUsed = 1L,
+ )
+ val creditCard2 = CreditCard(
+ guid = "id",
+ billingName = "Banana Apple",
+ encryptedCardNumber = CreditCardNumber.Encrypted("4111111111111110"),
+ cardNumberLast4 = "1110",
+ expiryMonth = 1,
+ expiryYear = 2030,
+ cardType = CreditCardNetworkType.AMEX.cardName,
+ timeCreated = 1L,
+ timeLastUsed = 1L,
+ timeLastModified = 1L,
+ timesUsed = 1L,
+ )
+
+ assertTrue(
+ CreditCardsAdapter.DiffCallback.areItemsTheSame(creditCard1, creditCard2),
+ )
+ assertTrue(
+ CreditCardsAdapter.DiffCallback.areContentsTheSame(creditCard1, creditCard2),
+ )
+
+ val creditCard3 = CreditCard(
+ guid = "id3",
+ billingName = "Banana Apple",
+ encryptedCardNumber = CreditCardNumber.Encrypted("4111111111111110"),
+ cardNumberLast4 = "1110",
+ expiryMonth = 1,
+ expiryYear = 2030,
+ cardType = CreditCardNetworkType.AMEX.cardName,
+ timeCreated = 1L,
+ timeLastUsed = 1L,
+ timeLastModified = 1L,
+ timesUsed = 1L,
+ )
+
+ assertFalse(
+ CreditCardsAdapter.DiffCallback.areItemsTheSame(creditCard1, creditCard3),
+ )
+ assertFalse(
+ CreditCardsAdapter.DiffCallback.areContentsTheSame(creditCard1, creditCard3),
+ )
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardsManagementViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardsManagementViewTest.kt
new file mode 100644
index 0000000000..ef272f527d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/CreditCardsManagementViewTest.kt
@@ -0,0 +1,61 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.creditcards
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.core.view.isVisible
+import io.mockk.mockk
+import mozilla.components.concept.storage.CreditCard
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.databinding.ComponentCreditCardsBinding
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.autofill.AutofillFragmentState
+import org.mozilla.fenix.settings.creditcards.interactor.CreditCardsManagementInteractor
+import org.mozilla.fenix.settings.creditcards.view.CreditCardsManagementView
+
+@RunWith(FenixRobolectricTestRunner::class)
+class CreditCardsManagementViewTest {
+
+ private lateinit var view: ViewGroup
+ private lateinit var interactor: CreditCardsManagementInteractor
+ private lateinit var creditCardsView: CreditCardsManagementView
+ private lateinit var componentCreditCardsBinding: ComponentCreditCardsBinding
+
+ @Before
+ fun setup() {
+ view = LayoutInflater.from(testContext).inflate(CreditCardsManagementView.LAYOUT_ID, null)
+ .findViewById(R.id.credit_cards_wrapper)
+ componentCreditCardsBinding = ComponentCreditCardsBinding.bind(view)
+ interactor = mockk(relaxed = true)
+
+ creditCardsView = CreditCardsManagementView(componentCreditCardsBinding, interactor)
+ }
+
+ @Test
+ fun testUpdate() {
+ creditCardsView.update(AutofillFragmentState())
+
+ assertTrue(componentCreditCardsBinding.progressBar.isVisible)
+ assertFalse(componentCreditCardsBinding.creditCardsList.isVisible)
+
+ val creditCards: List<CreditCard> = listOf(mockk(), mockk())
+ creditCardsView.update(
+ AutofillFragmentState(
+ creditCards = creditCards,
+ isLoading = false,
+ ),
+ )
+
+ assertFalse(componentCreditCardsBinding.progressBar.isVisible)
+ assertTrue(componentCreditCardsBinding.creditCardsList.isVisible)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/DefaultCreditCardEditorControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/DefaultCreditCardEditorControllerTest.kt
new file mode 100644
index 0000000000..5400b321f1
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/DefaultCreditCardEditorControllerTest.kt
@@ -0,0 +1,134 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.creditcards
+
+import android.content.DialogInterface
+import androidx.navigation.NavController
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.concept.storage.CreditCardNumber
+import mozilla.components.concept.storage.NewCreditCardFields
+import mozilla.components.concept.storage.UpdatableCreditCardFields
+import mozilla.components.service.sync.autofill.AutofillCreditCardsAddressesStorage
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import mozilla.components.support.utils.CreditCardNetworkType
+import mozilla.telemetry.glean.testing.GleanTestRule
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.CreditCards
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.creditcards.controller.DefaultCreditCardEditorController
+
+@RunWith(FenixRobolectricTestRunner::class) // for gleanTestRule
+class DefaultCreditCardEditorControllerTest {
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ private val storage: AutofillCreditCardsAddressesStorage = mockk(relaxed = true)
+ private val navController: NavController = mockk(relaxed = true)
+ private val showDeleteDialog = mockk<(DialogInterface.OnClickListener) -> Unit>()
+
+ private lateinit var controller: DefaultCreditCardEditorController
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val testDispatcher = coroutinesTestRule.testDispatcher
+ private val testCoroutineScope = coroutinesTestRule.scope
+
+ @Before
+ fun setup() {
+ every { showDeleteDialog(any()) } answers {
+ firstArg<DialogInterface.OnClickListener>().onClick(
+ mockk(relaxed = true),
+ mockk(relaxed = true),
+ )
+ }
+ controller = spyk(
+ DefaultCreditCardEditorController(
+ storage = storage,
+ lifecycleScope = testCoroutineScope,
+ navController = navController,
+ ioDispatcher = testDispatcher,
+ showDeleteDialog = showDeleteDialog,
+ ),
+ )
+ }
+
+ @Test
+ fun handleCancelButtonClicked() {
+ controller.handleCancelButtonClicked()
+
+ verify {
+ navController.popBackStack()
+ }
+ }
+
+ @Test
+ fun handleDeleteCreditCard() = runTestOnMain {
+ val creditCardId = "id"
+ assertNull(CreditCards.deleted.testGetValue())
+
+ controller.handleDeleteCreditCard(creditCardId)
+
+ coVerify {
+ storage.deleteCreditCard(creditCardId)
+ navController.popBackStack()
+ }
+ assertNotNull(CreditCards.deleted.testGetValue())
+ }
+
+ @Test
+ fun handleSaveCreditCard() = runTestOnMain {
+ val creditCardFields = NewCreditCardFields(
+ billingName = "Banana Apple",
+ plaintextCardNumber = CreditCardNumber.Plaintext("4111111111111112"),
+ cardNumberLast4 = "1112",
+ expiryMonth = 1,
+ expiryYear = 2030,
+ cardType = CreditCardNetworkType.DISCOVER.cardName,
+ )
+ assertNull(CreditCards.saved.testGetValue())
+
+ controller.handleSaveCreditCard(creditCardFields)
+
+ coVerify {
+ storage.addCreditCard(creditCardFields)
+ navController.popBackStack()
+ }
+ assertNotNull(CreditCards.saved.testGetValue())
+ }
+
+ @Test
+ fun handleUpdateCreditCard() = runTestOnMain {
+ val creditCardId = "id"
+ val creditCardFields = UpdatableCreditCardFields(
+ billingName = "Banana Apple",
+ cardNumber = CreditCardNumber.Plaintext("4111111111111112"),
+ cardNumberLast4 = "1112",
+ expiryMonth = 1,
+ expiryYear = 2034,
+ cardType = CreditCardNetworkType.DISCOVER.cardName,
+ )
+ assertNull(CreditCards.modified.testGetValue())
+
+ controller.handleUpdateCreditCard(creditCardId, creditCardFields)
+
+ coVerify {
+ storage.updateCreditCard(creditCardId, creditCardFields)
+ navController.popBackStack()
+ }
+ assertNotNull(CreditCards.modified.testGetValue())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/DefaultCreditCardEditorInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/DefaultCreditCardEditorInteractorTest.kt
new file mode 100644
index 0000000000..cdcbf9ee72
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/DefaultCreditCardEditorInteractorTest.kt
@@ -0,0 +1,83 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.creditcards
+
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.concept.storage.CreditCard
+import mozilla.components.concept.storage.CreditCardNumber
+import mozilla.components.concept.storage.NewCreditCardFields
+import mozilla.components.concept.storage.UpdatableCreditCardFields
+import mozilla.components.support.utils.CreditCardNetworkType
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.settings.creditcards.controller.CreditCardEditorController
+import org.mozilla.fenix.settings.creditcards.interactor.DefaultCreditCardEditorInteractor
+
+class DefaultCreditCardEditorInteractorTest {
+
+ private val controller: CreditCardEditorController = mockk(relaxed = true)
+
+ private lateinit var interactor: DefaultCreditCardEditorInteractor
+
+ @Before
+ fun setup() {
+ interactor = DefaultCreditCardEditorInteractor(controller)
+ }
+
+ @Test
+ fun onCancelButtonClicked() {
+ interactor.onCancelButtonClicked()
+ verify { controller.handleCancelButtonClicked() }
+ }
+
+ @Test
+ fun onDeleteCardButtonClicked() {
+ val creditCard = CreditCard(
+ guid = "id",
+ billingName = "Banana Apple",
+ encryptedCardNumber = CreditCardNumber.Encrypted("4111111111111110"),
+ cardNumberLast4 = "1110",
+ expiryMonth = 1,
+ expiryYear = 2030,
+ cardType = CreditCardNetworkType.AMEX.cardName,
+ timeCreated = 1L,
+ timeLastUsed = 1L,
+ timeLastModified = 1L,
+ timesUsed = 1L,
+ )
+ interactor.onDeleteCardButtonClicked(creditCard.guid)
+ verify { controller.handleDeleteCreditCard(creditCard.guid) }
+ }
+
+ @Test
+ fun onSaveButtonClicked() {
+ val creditCardFields = NewCreditCardFields(
+ billingName = "Banana Apple",
+ plaintextCardNumber = CreditCardNumber.Plaintext("4111111111111112"),
+ cardNumberLast4 = "1112",
+ expiryMonth = 1,
+ expiryYear = 2030,
+ cardType = CreditCardNetworkType.DISCOVER.cardName,
+ )
+ interactor.onSaveCreditCard(creditCardFields)
+ verify { controller.handleSaveCreditCard(creditCardFields) }
+ }
+
+ @Test
+ fun onUpdateCreditCard() {
+ val guid = "id"
+ val creditCardFields = UpdatableCreditCardFields(
+ billingName = "Banana Apple",
+ cardNumber = CreditCardNumber.Encrypted("4111111111111112"),
+ cardNumberLast4 = "1112",
+ expiryMonth = 1,
+ expiryYear = 2034,
+ cardType = CreditCardNetworkType.DISCOVER.cardName,
+ )
+ interactor.onUpdateCreditCard(guid, creditCardFields)
+ verify { controller.handleUpdateCreditCard(guid, creditCardFields) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/DefaultCreditCardsManagementControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/DefaultCreditCardsManagementControllerTest.kt
new file mode 100644
index 0000000000..55e01c9185
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/DefaultCreditCardsManagementControllerTest.kt
@@ -0,0 +1,71 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.creditcards
+
+import androidx.navigation.NavController
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.concept.storage.CreditCard
+import mozilla.components.concept.storage.CreditCardNumber
+import mozilla.components.support.utils.CreditCardNetworkType
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.settings.creditcards.controller.DefaultCreditCardsManagementController
+
+class DefaultCreditCardsManagementControllerTest {
+
+ private val navController: NavController = mockk(relaxed = true)
+
+ private lateinit var controller: DefaultCreditCardsManagementController
+
+ @Before
+ fun setup() {
+ controller = spyk(
+ DefaultCreditCardsManagementController(
+ navController = navController,
+ ),
+ )
+ }
+
+ @Test
+ fun handleCreditCardClicked() {
+ val creditCard = CreditCard(
+ guid = "id",
+ billingName = "Banana Apple",
+ expiryMonth = 1,
+ encryptedCardNumber = CreditCardNumber.Encrypted("4111111111111110"),
+ cardNumberLast4 = "1110",
+ expiryYear = 2030,
+ cardType = CreditCardNetworkType.AMEX.cardName,
+ timeCreated = 1L,
+ timeLastUsed = 1L,
+ timeLastModified = 1L,
+ timesUsed = 1L,
+ )
+
+ controller.handleCreditCardClicked(creditCard)
+
+ verify {
+ navController.navigate(
+ CreditCardsManagementFragmentDirections
+ .actionCreditCardsManagementFragmentToCreditCardEditorFragment(
+ creditCard = creditCard,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun handleAddCreditCardClicked() {
+ controller.handleAddCreditCardClicked()
+
+ verify {
+ navController.navigate(
+ CreditCardsManagementFragmentDirections.actionCreditCardsManagementFragmentToCreditCardEditorFragment(),
+ )
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/DefaultCreditCardsManagementInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/DefaultCreditCardsManagementInteractorTest.kt
new file mode 100644
index 0000000000..a85e50f165
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/DefaultCreditCardsManagementInteractorTest.kt
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.creditcards
+
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.concept.storage.CreditCard
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.telemetry.glean.testing.GleanTestRule
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.CreditCards
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.creditcards.controller.CreditCardsManagementController
+import org.mozilla.fenix.settings.creditcards.interactor.DefaultCreditCardsManagementInteractor
+
+@RunWith(FenixRobolectricTestRunner::class)
+class DefaultCreditCardsManagementInteractorTest {
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ private val controller: CreditCardsManagementController = mockk(relaxed = true)
+
+ private lateinit var interactor: DefaultCreditCardsManagementInteractor
+
+ @Before
+ fun setup() {
+ interactor = DefaultCreditCardsManagementInteractor(controller)
+ }
+
+ @Test
+ fun onSelectCreditCard() {
+ val creditCard: CreditCard = mockk(relaxed = true)
+ assertNull(CreditCards.managementCardTapped.testGetValue())
+
+ interactor.onSelectCreditCard(creditCard)
+ verify { controller.handleCreditCardClicked(creditCard) }
+ assertNotNull(CreditCards.managementCardTapped.testGetValue())
+ }
+
+ @Test
+ fun onClickAddCreditCard() {
+ assertNull(CreditCards.managementAddTapped.testGetValue())
+
+ interactor.onAddCreditCardClick()
+ verify { controller.handleAddCreditCardClicked() }
+ assertNotNull(CreditCards.managementAddTapped.testGetValue())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/StringTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/StringTest.kt
new file mode 100644
index 0000000000..3a0a4a114d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/creditcards/StringTest.kt
@@ -0,0 +1,107 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.creditcards
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class StringTest {
+
+ @Test
+ fun `toCreditCardNumber returns a string with only digits `() {
+ assertEquals("123456789", "1 234 5678 9".toCreditCardNumber())
+ assertEquals("123456789", "1.23.4+5678/9".toCreditCardNumber())
+ assertEquals("123456789", ",12r34t5678&9".toCreditCardNumber())
+ assertEquals("123456789", " 1 234 5678 9 ".toCreditCardNumber())
+ assertEquals("123456789", " abc 1 234 abc 5678 9".toCreditCardNumber())
+ assertEquals("123456789", "1-234-5678-9".toCreditCardNumber())
+ }
+
+ @Test
+ fun `last4Digits returns a string with only last 4 digits `() {
+ assertEquals("8431", "371449635398431".last4Digits())
+ assertEquals("2345", "12345".last4Digits())
+ assertEquals("1234", "1234".last4Digits())
+ assertEquals("123", "123".last4Digits())
+ assertEquals("1", "1".last4Digits())
+ assertEquals("", "".last4Digits())
+ }
+
+ @Test
+ fun `validateCreditCardNumber returns true for valid credit card numbers `() {
+ val americanExpressCard = "371449635398431"
+ val dinnersClubCard = "30569309025904"
+ val discoverCard = "6011111111111117"
+ val jcbCard = "3530111333300000"
+ val masterCardCard = "5555555555554444"
+ val visaCard = "4111111111111111"
+
+ assertTrue(americanExpressCard.validateCreditCardNumber())
+ assertTrue(dinnersClubCard.validateCreditCardNumber())
+ assertTrue(discoverCard.validateCreditCardNumber())
+ assertTrue(jcbCard.validateCreditCardNumber())
+ assertTrue(masterCardCard.validateCreditCardNumber())
+ assertTrue(visaCard.validateCreditCardNumber())
+ }
+
+ @Test
+ fun `validateCreditCardNumber returns false got invalid credit card numbers `() {
+ val shortCardNumber = "12345678901"
+ val longCardNumber = "12345678901234567890"
+
+ val americanExpressCardInvalid = "371449635398432"
+ val dinnersClubCardInvalid = "30569309025905"
+ val discoverCardInvalid = "6011111111111118"
+ val jcbCardInvalid = "3530111333300001"
+ val masterCardCardInvalid = "5555555555554445"
+ val visaCardInvalid = "4111111111111112"
+ val voyagerCardInvalid = "869941728035896"
+
+ assertFalse(shortCardNumber.validateCreditCardNumber())
+ assertFalse(longCardNumber.validateCreditCardNumber())
+
+ assertFalse(americanExpressCardInvalid.validateCreditCardNumber())
+ assertFalse(dinnersClubCardInvalid.validateCreditCardNumber())
+ assertFalse(discoverCardInvalid.validateCreditCardNumber())
+ assertFalse(jcbCardInvalid.validateCreditCardNumber())
+ assertFalse(masterCardCardInvalid.validateCreditCardNumber())
+ assertFalse(visaCardInvalid.validateCreditCardNumber())
+ assertFalse(voyagerCardInvalid.validateCreditCardNumber())
+ }
+
+ @Test
+ fun `luhnAlgorithmValidation returns false for invalid identification numbers `() {
+ // "4242424242424242" is a valid identification number
+ assertFalse(luhnAlgorithmValidation("4242424242424240"))
+ assertFalse(luhnAlgorithmValidation("4242424242424241"))
+ assertFalse(luhnAlgorithmValidation("4242424242424243"))
+ assertFalse(luhnAlgorithmValidation("4242424242424244"))
+ assertFalse(luhnAlgorithmValidation("4242424242424245"))
+ assertFalse(luhnAlgorithmValidation("4242424242424246"))
+ assertFalse(luhnAlgorithmValidation("4242424242424247"))
+ assertFalse(luhnAlgorithmValidation("4242424242424248"))
+ assertFalse(luhnAlgorithmValidation("4242424242424249"))
+ assertFalse(luhnAlgorithmValidation("1"))
+ assertFalse(luhnAlgorithmValidation("12"))
+ assertFalse(luhnAlgorithmValidation("123"))
+ }
+
+ @Test
+ fun `luhnAlgorithmValidation returns true for valid identification numbers `() {
+ assertTrue(luhnAlgorithmValidation("0"))
+ assertTrue(luhnAlgorithmValidation("00"))
+ assertTrue(luhnAlgorithmValidation("18"))
+ assertTrue(luhnAlgorithmValidation("0000000000000000"))
+ assertTrue(luhnAlgorithmValidation("4242424242424242"))
+ assertTrue(luhnAlgorithmValidation("42424242424242426"))
+ assertTrue(luhnAlgorithmValidation("424242424242424267"))
+ assertTrue(luhnAlgorithmValidation("4242424242424242675"))
+ assertTrue(luhnAlgorithmValidation("000000018"))
+ assertTrue(luhnAlgorithmValidation("99999999999999999999"))
+ assertTrue(luhnAlgorithmValidation("1234567812345670"))
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/deletebrowsingdata/DefaultDeleteBrowsingDataControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/deletebrowsingdata/DefaultDeleteBrowsingDataControllerTest.kt
new file mode 100644
index 0000000000..cecd0f0aec
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/deletebrowsingdata/DefaultDeleteBrowsingDataControllerTest.kt
@@ -0,0 +1,122 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.deletebrowsingdata
+
+import io.mockk.coVerify
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import mozilla.components.browser.icons.BrowserIcons
+import mozilla.components.browser.state.action.EngineAction
+import mozilla.components.browser.state.action.RecentlyClosedAction
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.Engine
+import mozilla.components.concept.storage.HistoryStorage
+import mozilla.components.feature.downloads.DownloadsUseCases
+import mozilla.components.feature.tabs.TabsUseCases
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.components.PermissionStorage
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class DefaultDeleteBrowsingDataControllerTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ private var removeAllTabs: TabsUseCases.RemoveAllTabsUseCase = mockk(relaxed = true)
+ private var removeAllDownloads: DownloadsUseCases.RemoveAllDownloadsUseCase = mockk(relaxed = true)
+ private var historyStorage: HistoryStorage = mockk(relaxed = true)
+ private var permissionStorage: PermissionStorage = mockk(relaxed = true)
+ private var store: BrowserStore = mockk(relaxed = true)
+ private var iconsStorage: BrowserIcons = mockk(relaxed = true)
+ private val engine: Engine = mockk(relaxed = true)
+ private lateinit var controller: DefaultDeleteBrowsingDataController
+
+ @Before
+ @OptIn(DelicateCoroutinesApi::class) // coroutineContext usage
+ fun setup() {
+ controller = DefaultDeleteBrowsingDataController(
+ removeAllTabs = removeAllTabs,
+ removeAllDownloads = removeAllDownloads,
+ historyStorage = historyStorage,
+ store = store,
+ permissionStorage = permissionStorage,
+ iconsStorage = iconsStorage,
+ engine = engine,
+ coroutineContext = coroutinesTestRule.testDispatcher,
+ )
+ }
+
+ @Test
+ fun deleteTabs() = runTestOnMain {
+ controller.deleteTabs()
+
+ verify {
+ removeAllTabs.invoke(false)
+ }
+ }
+
+ @Test
+ fun deleteBrowsingHistory() = runTestOnMain {
+ controller = spyk(controller)
+ controller.deleteBrowsingHistory()
+
+ coVerify {
+ historyStorage.deleteEverything()
+ store.dispatch(EngineAction.PurgeHistoryAction)
+ store.dispatch(RecentlyClosedAction.RemoveAllClosedTabAction)
+ iconsStorage.clear()
+ }
+ }
+
+ @Test
+ fun deleteCookiesAndSiteData() = runTestOnMain {
+ controller.deleteCookiesAndSiteData()
+
+ verify {
+ engine.clearData(
+ Engine.BrowsingData.select(
+ Engine.BrowsingData.COOKIES,
+ Engine.BrowsingData.AUTH_SESSIONS,
+ ),
+ )
+ engine.clearData(Engine.BrowsingData.select(Engine.BrowsingData.DOM_STORAGES))
+ }
+ }
+
+ @Test
+ fun deleteCachedFiles() = runTestOnMain {
+ controller.deleteCachedFiles()
+
+ verify {
+ engine.clearData(Engine.BrowsingData.select(Engine.BrowsingData.ALL_CACHES))
+ }
+ }
+
+ @Test
+ fun deleteSitePermissions() = runTestOnMain {
+ controller.deleteSitePermissions()
+
+ coVerify {
+ engine.clearData(Engine.BrowsingData.select(Engine.BrowsingData.ALL_SITE_SETTINGS))
+ permissionStorage.deleteAllSitePermissions()
+ }
+ }
+
+ @Test
+ fun deleteDownloads() = runTestOnMain {
+ controller.deleteDownloads()
+
+ verify {
+ removeAllDownloads.invoke()
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/deletebrowsingdata/DeleteAndQuitTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/deletebrowsingdata/DeleteAndQuitTest.kt
new file mode 100644
index 0000000000..5e42f4cc07
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/deletebrowsingdata/DeleteAndQuitTest.kt
@@ -0,0 +1,149 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@file:Suppress("DEPRECATION")
+
+package org.mozilla.fenix.settings.deletebrowsingdata
+
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verifyOrder
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import mozilla.components.browser.icons.BrowserIcons
+import mozilla.components.browser.storage.sync.PlacesHistoryStorage
+import mozilla.components.concept.engine.Engine
+import mozilla.components.feature.downloads.DownloadsUseCases.RemoveAllDownloadsUseCase
+import mozilla.components.feature.tabs.TabsUseCases
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Before
+import org.junit.Ignore
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.components.FenixSnackbar
+import org.mozilla.fenix.components.PermissionStorage
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.settings.deletebrowsingdata.DeleteBrowsingDataOnQuitType.CACHE
+import org.mozilla.fenix.settings.deletebrowsingdata.DeleteBrowsingDataOnQuitType.COOKIES
+import org.mozilla.fenix.settings.deletebrowsingdata.DeleteBrowsingDataOnQuitType.DOWNLOADS
+import org.mozilla.fenix.settings.deletebrowsingdata.DeleteBrowsingDataOnQuitType.HISTORY
+import org.mozilla.fenix.settings.deletebrowsingdata.DeleteBrowsingDataOnQuitType.PERMISSIONS
+import org.mozilla.fenix.settings.deletebrowsingdata.DeleteBrowsingDataOnQuitType.TABS
+import org.mozilla.fenix.utils.Settings
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class DeleteAndQuitTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ private val activity: HomeActivity = mockk(relaxed = true)
+ private val settings: Settings = mockk(relaxed = true)
+ private val tabUseCases: TabsUseCases = mockk(relaxed = true)
+ private val historyStorage: PlacesHistoryStorage = mockk(relaxed = true)
+ private val permissionStorage: PermissionStorage = mockk(relaxed = true)
+ private val iconsStorage: BrowserIcons = mockk()
+ private val engine: Engine = mockk(relaxed = true)
+ private val removeAllTabsUseCases: TabsUseCases.RemoveAllTabsUseCase = mockk(relaxed = true)
+ private val snackbar = mockk<FenixSnackbar>(relaxed = true)
+ private val downloadsUseCases: RemoveAllDownloadsUseCase = mockk(relaxed = true)
+
+ @Before
+ fun setUp() {
+ every { activity.components.core.historyStorage } returns historyStorage
+ every { activity.components.core.permissionStorage } returns permissionStorage
+ every { activity.components.useCases.tabsUseCases } returns tabUseCases
+ every { activity.components.useCases.downloadUseCases.removeAllDownloads } returns downloadsUseCases
+ every { tabUseCases.removeAllTabs } returns removeAllTabsUseCases
+ every { activity.components.core.engine } returns engine
+ every { activity.components.settings } returns settings
+ every { activity.components.core.icons } returns iconsStorage
+ }
+
+ @Ignore("Failing test; need more investigation.")
+ @Test
+ fun `delete only tabs and quit`() = runTestOnMain {
+ // When
+ every { settings.getDeleteDataOnQuit(TABS) } returns true
+
+ deleteAndQuit(activity, this, snackbar)
+
+ advanceUntilIdle()
+
+ verifyOrder {
+ snackbar.show()
+ removeAllTabsUseCases.invoke(false)
+ activity.finishAndRemoveTask()
+ }
+
+ coVerify(exactly = 0) {
+ engine.clearData(
+ Engine.BrowsingData.select(
+ Engine.BrowsingData.COOKIES,
+ ),
+ )
+
+ permissionStorage.deleteAllSitePermissions()
+
+ engine.clearData(Engine.BrowsingData.allCaches())
+ }
+
+ coVerify(exactly = 0) {
+ historyStorage.deleteEverything()
+ iconsStorage.clear()
+ }
+ }
+
+ @Ignore("Failing test; need more investigation.")
+ @Test
+ fun `delete everything and quit`() = runTestOnMain {
+ // When
+ every { settings.getDeleteDataOnQuit(TABS) } returns true
+ every { settings.getDeleteDataOnQuit(HISTORY) } returns true
+ every { settings.getDeleteDataOnQuit(COOKIES) } returns true
+ every { settings.getDeleteDataOnQuit(CACHE) } returns true
+ every { settings.getDeleteDataOnQuit(PERMISSIONS) } returns true
+ every { settings.getDeleteDataOnQuit(DOWNLOADS) } returns true
+
+ deleteAndQuit(activity, this, snackbar)
+
+ advanceUntilIdle()
+
+ coVerify(exactly = 1) {
+ snackbar.show()
+
+ // Delete tabs
+ removeAllTabsUseCases.invoke(false)
+
+ // Delete browsing data
+ engine.clearData(Engine.BrowsingData.select(Engine.BrowsingData.DOM_STORAGES))
+ historyStorage.deleteEverything()
+ iconsStorage.clear()
+
+ // Delete cookies
+ engine.clearData(
+ Engine.BrowsingData.select(
+ Engine.BrowsingData.COOKIES,
+ Engine.BrowsingData.AUTH_SESSIONS,
+ ),
+ )
+
+ // Delete cached files
+ engine.clearData(Engine.BrowsingData.select(Engine.BrowsingData.ALL_CACHES))
+
+ // Delete permissions
+ engine.clearData(Engine.BrowsingData.select(Engine.BrowsingData.ALL_SITE_SETTINGS))
+ permissionStorage.deleteAllSitePermissions()
+
+ // Delete downloads
+ downloadsUseCases.invoke()
+
+ // Finish activity
+ activity.finishAndRemoveTask()
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/AddLoginInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/AddLoginInteractorTest.kt
new file mode 100644
index 0000000000..bc7d72cea0
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/AddLoginInteractorTest.kt
@@ -0,0 +1,51 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.logins
+
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Test
+import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
+import org.mozilla.fenix.settings.logins.interactor.AddLoginInteractor
+
+class AddLoginInteractorTest {
+
+ private val loginsController: SavedLoginsStorageController = mockk(relaxed = true)
+ private val interactor = AddLoginInteractor(loginsController)
+
+ private val hostname = "https://www.cats.com"
+ private val username = "myFunUsername111"
+ private val password = "superDuperSecure123!"
+
+ @Test
+ fun findPotentialDupesTest() {
+ interactor.findDuplicate(
+ hostname,
+ username,
+ password,
+ )
+
+ verify {
+ loginsController.findDuplicateForAdd(
+ hostname,
+ username,
+ password,
+ )
+ }
+ }
+
+ @Test
+ fun addNewLoginTest() {
+ interactor.onAddLogin(hostname, username, password)
+
+ verify {
+ loginsController.add(
+ hostname,
+ username,
+ password,
+ )
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/EditLoginInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/EditLoginInteractorTest.kt
new file mode 100644
index 0000000000..dfc81af9ac
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/EditLoginInteractorTest.kt
@@ -0,0 +1,34 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.logins
+
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Test
+import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
+import org.mozilla.fenix.settings.logins.interactor.EditLoginInteractor
+
+class EditLoginInteractorTest {
+ private val loginsController: SavedLoginsStorageController = mockk(relaxed = true)
+ private val interactor = EditLoginInteractor(loginsController)
+
+ @Test
+ fun findDuplicateTest() {
+ val id = "anyId"
+ interactor.findDuplicate(id, "username", "password")
+ verify { loginsController.findDuplicateForSave(id, "username", "password") }
+ }
+
+ @Test
+ fun saveLoginTest() {
+ val id = "anyId"
+ val username = "usernameText"
+ val password = "passwordText"
+
+ interactor.onSaveLogin(id, username, password)
+
+ verify { loginsController.save(id, username, password) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/LoginDetailInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/LoginDetailInteractorTest.kt
new file mode 100644
index 0000000000..644e41e12e
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/LoginDetailInteractorTest.kt
@@ -0,0 +1,30 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.logins
+
+import io.mockk.mockk
+import io.mockk.verifyAll
+import org.junit.Test
+import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
+import org.mozilla.fenix.settings.logins.interactor.LoginDetailInteractor
+
+class LoginDetailInteractorTest {
+ private val loginsController: SavedLoginsStorageController = mockk(relaxed = true)
+ private val interactor = LoginDetailInteractor(loginsController)
+
+ @Test
+ fun fetchLoginListTest() {
+ val id = "anyId"
+ interactor.onFetchLoginList(id)
+ verifyAll { loginsController.fetchLoginDetails(id) }
+ }
+
+ @Test
+ fun deleteLoginTest() {
+ val id = "anyId"
+ interactor.onDeleteLogin(id)
+ verifyAll { loginsController.delete(id) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/LoginDetailViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/LoginDetailViewTest.kt
new file mode 100644
index 0000000000..e6f890db15
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/LoginDetailViewTest.kt
@@ -0,0 +1,65 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.logins
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.databinding.FragmentLoginDetailBinding
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.logins.view.LoginDetailsBindingDelegate
+
+@RunWith(FenixRobolectricTestRunner::class)
+class LoginDetailViewTest {
+
+ private val state = LoginsListState(
+ loginList = emptyList(),
+ filteredItems = emptyList(),
+ currentItem = SavedLogin(
+ guid = "abcd",
+ origin = "mozilla.org",
+ username = "admin",
+ password = "password",
+ timeLastUsed = 100L,
+ ),
+ searchedForText = null,
+ sortingStrategy = SortingStrategy.LastUsed,
+ highlightedItem = SavedLoginsSortingStrategyMenu.Item.LastUsedSort,
+ duplicateLogin = null,
+ )
+
+ private lateinit var view: ViewGroup
+ private lateinit var binding: FragmentLoginDetailBinding
+ private lateinit var detailsBindingDelegate: LoginDetailsBindingDelegate
+
+ @Before
+ fun setup() {
+ binding = FragmentLoginDetailBinding.inflate(LayoutInflater.from(testContext))
+ view = binding.loginDetailLayout
+ detailsBindingDelegate = LoginDetailsBindingDelegate(binding)
+ }
+
+ @Test
+ fun `bind currentItem`() {
+ detailsBindingDelegate.update(state)
+
+ assertEquals("mozilla.org", binding.webAddressText.text)
+ assertEquals("admin", binding.usernameText.text)
+ assertEquals("password", binding.passwordText.text)
+ }
+
+ @Test
+ fun `bind null currentItem`() {
+ detailsBindingDelegate.update(state.copy(currentItem = null))
+
+ assertEquals("", binding.webAddressText.text)
+ assertEquals("", binding.usernameText.text)
+ assertEquals("", binding.passwordText.text)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/LoginsFragmentStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/LoginsFragmentStoreTest.kt
new file mode 100644
index 0000000000..9de55c14bb
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/LoginsFragmentStoreTest.kt
@@ -0,0 +1,237 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.logins
+
+import io.mockk.every
+import io.mockk.mockk
+import mozilla.components.concept.storage.Login
+import mozilla.components.support.test.ext.joinBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.mozilla.fenix.utils.Settings
+
+class LoginsFragmentStoreTest {
+
+ private val baseLogin = SavedLogin(
+ guid = "",
+ origin = "",
+ username = "",
+ password = "",
+ timeLastUsed = 0L,
+ )
+ private val exampleLogin = baseLogin.copy(guid = "example", origin = "example.com", timeLastUsed = 10)
+ private val firefoxLogin = baseLogin.copy(guid = "firefox", origin = "firefox.com", timeLastUsed = 20)
+ private val loginList = listOf(exampleLogin, firefoxLogin)
+ private val baseState = LoginsListState(
+ loginList = emptyList(),
+ filteredItems = emptyList(),
+ searchedForText = null,
+ sortingStrategy = SortingStrategy.LastUsed,
+ highlightedItem = SavedLoginsSortingStrategyMenu.Item.LastUsedSort,
+ duplicateLogin = null,
+ )
+
+ @Test
+ fun `create initial state`() {
+ val settings = mockk<Settings>()
+ every { settings.savedLoginsSortingStrategy } returns SortingStrategy.LastUsed
+ every { settings.savedLoginsMenuHighlightedItem } returns SavedLoginsSortingStrategyMenu.Item.LastUsedSort
+
+ assertEquals(
+ LoginsListState(
+ isLoading = true,
+ loginList = emptyList(),
+ filteredItems = emptyList(),
+ searchedForText = null,
+ sortingStrategy = SortingStrategy.LastUsed,
+ highlightedItem = SavedLoginsSortingStrategyMenu.Item.LastUsedSort,
+ duplicateLogin = null,
+ ),
+ createInitialLoginsListState(settings),
+ )
+ }
+
+ @Test
+ fun `convert login to saved login`() {
+ val login = Login(
+ guid = "abcd",
+ origin = "example.com",
+ username = "login",
+ password = "password",
+ timeLastUsed = 35L,
+ ).mapToSavedLogin()
+
+ assertEquals("abcd", login.guid)
+ assertEquals("example.com", login.origin)
+ assertEquals("login", login.username)
+ assertEquals("password", login.password)
+ assertEquals(35L, login.timeLastUsed)
+ }
+
+ @Test
+ fun `UpdateLoginsList action`() {
+ val store = LoginsFragmentStore(baseState.copy(isLoading = true))
+
+ store.dispatch(LoginsAction.UpdateLoginsList(loginList)).joinBlocking()
+
+ assertFalse(store.state.isLoading)
+ assertEquals(loginList, store.state.loginList)
+ assertEquals(listOf(firefoxLogin, exampleLogin), store.state.filteredItems)
+ }
+
+ @Test
+ fun `GIVEN logins already exist WHEN asked to add a new one THEN update the state to contain them all`() {
+ val store = LoginsFragmentStore(
+ baseState.copy(isLoading = true),
+ )
+
+ store.dispatch(LoginsAction.AddLogin(exampleLogin)).joinBlocking()
+ assertFalse(store.state.isLoading)
+ assertEquals(listOf(exampleLogin), store.state.loginList)
+
+ // Select a login to force "isLoading = true"
+ store.dispatch(LoginsAction.AddLogin(firefoxLogin)).joinBlocking()
+ assertFalse(store.state.isLoading)
+ assertEquals(loginList, store.state.loginList)
+ assertEquals(listOf(firefoxLogin, exampleLogin), store.state.filteredItems)
+ }
+
+ @Test
+ fun `GIVEN logins already exist WHEN asked to update one THEN update the available logins`() {
+ val store = LoginsFragmentStore(
+ baseState.copy(
+ isLoading = true,
+ loginList = loginList,
+ ),
+ )
+ val updatedLogin = firefoxLogin.copy(origin = "test")
+
+ store.dispatch(LoginsAction.UpdateLogin(firefoxLogin.guid, updatedLogin)).joinBlocking()
+ assertFalse(store.state.isLoading)
+ assertEquals(listOf(exampleLogin, updatedLogin), store.state.loginList)
+
+ // Test updating a non-existent login
+ store.dispatch(LoginsAction.UpdateLogin("test", updatedLogin.copy(origin = "none"))).joinBlocking()
+ assertFalse(store.state.isLoading)
+ assertEquals(listOf(exampleLogin, updatedLogin), store.state.loginList)
+ }
+
+ @Test
+ fun `GIVEN logins already exist WHEN asked to remove one THEN update the state to not contain it`() {
+ val store = LoginsFragmentStore(
+ baseState.copy(
+ isLoading = true,
+ loginList = loginList,
+ ),
+ )
+
+ store.dispatch(LoginsAction.DeleteLogin("not_existing")).joinBlocking()
+ assertEquals(loginList, store.state.loginList)
+ assertEquals(listOf(firefoxLogin, exampleLogin), store.state.filteredItems)
+
+ store.dispatch(LoginsAction.DeleteLogin(exampleLogin.guid)).joinBlocking()
+ assertEquals(listOf(firefoxLogin), store.state.loginList)
+ assertEquals(listOf(firefoxLogin), store.state.filteredItems)
+
+ store.dispatch(LoginsAction.DeleteLogin(firefoxLogin.guid)).joinBlocking()
+ assertEquals(emptyList<SavedLogin>(), store.state.loginList)
+ assertEquals(emptyList<SavedLogin>(), store.state.filteredItems)
+
+ // Test deleting from an empty store
+ store.dispatch(LoginsAction.DeleteLogin(firefoxLogin.guid)).joinBlocking()
+ assertEquals(emptyList<SavedLogin>(), store.state.loginList)
+ assertEquals(emptyList<SavedLogin>(), store.state.filteredItems)
+ }
+
+ @Test
+ fun `FilterLogins action`() {
+ val store = LoginsFragmentStore(
+ baseState.copy(
+ isLoading = true,
+ searchedForText = "firefox",
+ loginList = loginList,
+ ),
+ )
+
+ store.dispatch(LoginsAction.FilterLogins(null)).joinBlocking()
+
+ assertFalse(store.state.isLoading)
+ assertNull(store.state.searchedForText)
+ assertEquals(listOf(firefoxLogin, exampleLogin), store.state.filteredItems)
+ }
+
+ @Test
+ fun `UpdateCurrentLogin action`() {
+ val store = LoginsFragmentStore(baseState.copy(isLoading = true))
+
+ store.dispatch(LoginsAction.UpdateCurrentLogin(baseLogin)).joinBlocking()
+
+ assertEquals(baseLogin, store.state.currentItem)
+ }
+
+ @Test
+ fun `SortLogins action`() {
+ val lastUsed = SortingStrategy.LastUsed
+ val store = LoginsFragmentStore(
+ baseState.copy(
+ isLoading = true,
+ searchedForText = null,
+ sortingStrategy = SortingStrategy.Alphabetically,
+ highlightedItem = SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort,
+ loginList = loginList,
+ ),
+ )
+
+ store.dispatch(LoginsAction.SortLogins(lastUsed)).joinBlocking()
+
+ assertFalse(store.state.isLoading)
+ assertEquals(lastUsed, store.state.sortingStrategy)
+ assertEquals(SavedLoginsSortingStrategyMenu.Item.LastUsedSort, store.state.highlightedItem)
+ assertNull(store.state.searchedForText)
+ assertEquals(listOf(firefoxLogin, exampleLogin), store.state.filteredItems)
+ }
+
+ @Test
+ fun `SortLogins action with search text`() {
+ val lastUsed = SortingStrategy.LastUsed
+ val store = LoginsFragmentStore(
+ baseState.copy(
+ isLoading = true,
+ searchedForText = "example",
+ sortingStrategy = SortingStrategy.Alphabetically,
+ highlightedItem = SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort,
+ loginList = loginList,
+ ),
+ )
+
+ store.dispatch(LoginsAction.SortLogins(lastUsed)).joinBlocking()
+
+ assertFalse(store.state.isLoading)
+ assertEquals(lastUsed, store.state.sortingStrategy)
+ assertEquals(SavedLoginsSortingStrategyMenu.Item.LastUsedSort, store.state.highlightedItem)
+ assertEquals("example", store.state.searchedForText)
+ assertEquals(listOf(exampleLogin), store.state.filteredItems)
+ }
+
+ @Test
+ fun `LoginSelected action`() {
+ val store = LoginsFragmentStore(
+ baseState.copy(
+ isLoading = false,
+ loginList = listOf(mockk()),
+ filteredItems = listOf(mockk()),
+ ),
+ )
+
+ store.dispatch(LoginsAction.LoginSelected(mockk())).joinBlocking()
+
+ assertTrue(store.state.isLoading)
+ assertTrue(store.state.loginList.isNotEmpty())
+ assertTrue(store.state.filteredItems.isNotEmpty())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/LoginsListControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/LoginsListControllerTest.kt
new file mode 100644
index 0000000000..cff0d9d40d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/LoginsListControllerTest.kt
@@ -0,0 +1,98 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.logins
+
+import androidx.navigation.NavController
+import io.mockk.mockk
+import io.mockk.verifyAll
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.BrowserDirection
+import org.mozilla.fenix.GleanMetrics.Logins
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.SupportUtils
+import org.mozilla.fenix.settings.logins.controller.LoginsListController
+import org.mozilla.fenix.settings.logins.fragment.SavedLoginsFragmentDirections
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class LoginsListControllerTest {
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ private val store: LoginsFragmentStore = mockk(relaxed = true)
+ private val settings: Settings = mockk(relaxed = true)
+ private val sortingStrategy: SortingStrategy = SortingStrategy.Alphabetically
+ private val navController: NavController = mockk(relaxed = true)
+ private val browserNavigator: (String, Boolean, BrowserDirection) -> Unit = mockk(relaxed = true)
+ private val addLoginCallback: () -> Unit = mockk(relaxed = true)
+ private val controller =
+ LoginsListController(
+ loginsFragmentStore = store,
+ navController = navController,
+ browserNavigator = browserNavigator,
+ settings = settings,
+ addLoginCallback = addLoginCallback,
+ )
+
+ @Test
+ fun `handle selecting the sorting strategy and save pref`() {
+ controller.handleSort(sortingStrategy)
+
+ verifyAll {
+ store.dispatch(LoginsAction.SortLogins(SortingStrategy.Alphabetically))
+ settings.savedLoginsSortingStrategy = sortingStrategy
+ }
+ }
+
+ @Test
+ fun `handle login item clicked`() {
+ val login: SavedLogin = mockk(relaxed = true)
+ assertNull(Logins.openIndividualLogin.testGetValue())
+
+ controller.handleItemClicked(login)
+
+ verifyAll {
+ store.dispatch(LoginsAction.LoginSelected(login))
+ navController.navigate(
+ SavedLoginsFragmentDirections.actionSavedLoginsFragmentToLoginDetailFragment(login.guid),
+ )
+ }
+
+ assertNotNull(Logins.openIndividualLogin.testGetValue())
+ assertEquals(1, Logins.openIndividualLogin.testGetValue()!!.size)
+ assertNull(Logins.openIndividualLogin.testGetValue()!!.single().extra)
+ }
+
+ @Test
+ fun `Open the correct support webpage when Learn More is clicked`() {
+ controller.handleLearnMoreClicked()
+
+ verifyAll {
+ browserNavigator.invoke(
+ SupportUtils.getGenericSumoURLForTopic(SupportUtils.SumoTopic.SYNC_SETUP),
+ true,
+ BrowserDirection.FromSavedLoginsFragment,
+ )
+ }
+ }
+
+ @Test
+ fun `handle add login clicked`() {
+ controller.handleAddLoginClicked()
+
+ verifyAll {
+ navController.navigate(
+ SavedLoginsFragmentDirections.actionSavedLoginsFragmentToAddLoginFragment(),
+ )
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/LoginsListViewHolderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/LoginsListViewHolderTest.kt
new file mode 100644
index 0000000000..b3d99e2878
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/LoginsListViewHolderTest.kt
@@ -0,0 +1,75 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.logins
+
+import android.view.LayoutInflater
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.browser.icons.BrowserIcons
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.databinding.LoginsItemBinding
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor
+import org.mozilla.fenix.settings.logins.view.LoginsListViewHolder
+
+@RunWith(FenixRobolectricTestRunner::class)
+class LoginsListViewHolderTest {
+
+ private val baseLogin = SavedLogin(
+ guid = "abcd",
+ origin = "https://www.mozilla.org",
+ username = "admin",
+ password = "password",
+ timeLastUsed = 100L,
+ )
+
+ private lateinit var interactor: SavedLoginsInteractor
+ private lateinit var binding: LoginsItemBinding
+ private lateinit var holder: LoginsListViewHolder
+
+ @Before
+ fun setup() {
+ binding = LoginsItemBinding.inflate(LayoutInflater.from(testContext))
+ interactor = mockk(relaxed = true)
+ holder = LoginsListViewHolder(
+ binding.root,
+ interactor,
+ )
+ every { testContext.components.core.icons } returns BrowserIcons(testContext, mockk(relaxed = true))
+ }
+
+ @Test
+ fun `bind url and username`() {
+ holder.bind(baseLogin)
+
+ assertEquals("mozilla.org", binding.webAddressView.text)
+ assertEquals("admin", binding.usernameView.text)
+ }
+
+ @Test
+ fun `GIVEN url has a mobile prefix WHEN url is binded THEN mobile prefix is stripped`() {
+ holder.bind(baseLogin.copy(origin = "https://m.mozilla.org"))
+
+ assertEquals("mozilla.org", binding.webAddressView.text)
+
+ holder.bind(baseLogin.copy(origin = "https://mobile.mozilla.org"))
+
+ assertEquals("mozilla.org", binding.webAddressView.text)
+ }
+
+ @Test
+ fun `call interactor on click`() {
+ holder.bind(baseLogin)
+
+ binding.root.performClick()
+ verify { interactor.onItemClicked(baseLogin) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsInteractorTest.kt
new file mode 100644
index 0000000000..347663f94a
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsInteractorTest.kt
@@ -0,0 +1,73 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.logins
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verifyAll
+import mozilla.components.lib.publicsuffixlist.PublicSuffixList
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.logins.controller.LoginsListController
+import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
+import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor
+import kotlin.random.Random
+
+@RunWith(FenixRobolectricTestRunner::class)
+class SavedLoginsInteractorTest {
+ private val listController: LoginsListController = mockk(relaxed = true)
+ private val savedLoginsStorageController: SavedLoginsStorageController = mockk(relaxed = true)
+ private lateinit var interactor: SavedLoginsInteractor
+
+ @Before
+ fun setup() {
+ interactor = SavedLoginsInteractor(listController, savedLoginsStorageController)
+ }
+
+ @Test
+ fun `GIVEN a SavedLogin being clicked, WHEN the interactor is called for it, THEN it should just delegate the controller`() {
+ val item = SavedLogin("mozilla.org", "username", "password", "id", Random.nextLong())
+ interactor.onItemClicked(item)
+
+ verifyAll {
+ listController.handleItemClicked(item)
+ }
+ }
+
+ @Test
+ fun `GIVEN a change in sorting strategy, WHEN the interactor is called for it, THEN it should just delegate the controller`() {
+ every { testContext.components.publicSuffixList } returns PublicSuffixList(testContext)
+ interactor.onSortingStrategyChanged(SortingStrategy.Alphabetically)
+
+ verifyAll {
+ listController.handleSort(SortingStrategy.Alphabetically)
+ }
+ }
+
+ @Test
+ fun `GIVEN the learn more option is clicked, WHEN the interactor is called for it, THEN it should just delegate the controller`() {
+ interactor.onLearnMoreClicked()
+
+ verifyAll {
+ listController.handleLearnMoreClicked()
+ }
+ }
+
+ @Test
+ fun loadAndMapLoginsTest() {
+ interactor.loadAndMapLogins()
+ verifyAll { savedLoginsStorageController.handleLoadAndMapLogins() }
+ }
+
+ @Test
+ fun `Handle add login button click`() {
+ interactor.onAddLoginClick()
+ verifyAll { listController.handleAddLoginClicked() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsSortingStrategyMenuTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsSortingStrategyMenuTest.kt
new file mode 100644
index 0000000000..5e025ec057
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsSortingStrategyMenuTest.kt
@@ -0,0 +1,85 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.logins
+
+import android.content.Context
+import androidx.appcompat.view.ContextThemeWrapper
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.concept.menu.candidate.HighPriorityHighlightEffect
+import mozilla.components.support.ktx.android.content.getColorFromAttr
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.logins.SavedLoginsSortingStrategyMenu.Item
+import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor
+
+@RunWith(FenixRobolectricTestRunner::class)
+class SavedLoginsSortingStrategyMenuTest {
+
+ private lateinit var context: Context
+ private lateinit var interactor: SavedLoginsInteractor
+ private lateinit var menu: SavedLoginsSortingStrategyMenu
+
+ @Before
+ fun setup() {
+ context = ContextThemeWrapper(testContext, R.style.NormalTheme)
+ interactor = mockk()
+ menu = SavedLoginsSortingStrategyMenu(context, interactor)
+ }
+
+ @Test
+ fun `item enum can be deserialized from string`() {
+ assertEquals(Item.AlphabeticallySort, Item.fromString("ALPHABETICALLY"))
+ assertEquals(Item.LastUsedSort, Item.fromString("LAST_USED"))
+ assertEquals(Item.AlphabeticallySort, Item.fromString("OTHER"))
+ }
+
+ @Test
+ fun `effect is set on alphabetical sort candidate`() {
+ val (name, lastUsed) = menu.menuItems(Item.AlphabeticallySort)
+ assertEquals(
+ HighPriorityHighlightEffect(context.getColorFromAttr(R.attr.colorControlHighlight)),
+ name.effect,
+ )
+ assertNull(lastUsed.effect)
+ }
+
+ @Test
+ fun `effect is set on last used sort candidate`() {
+ val (name, lastUsed) = menu.menuItems(Item.LastUsedSort)
+ assertNull(name.effect)
+ assertEquals(
+ HighPriorityHighlightEffect(context.getColorFromAttr(R.attr.colorControlHighlight)),
+ lastUsed.effect,
+ )
+ }
+
+ @Test
+ fun `candidates call interactor on click`() {
+ val (name, lastUsed) = menu.menuItems(Item.AlphabeticallySort)
+ every { interactor.onSortingStrategyChanged(any()) } just Runs
+
+ name.onClick()
+ verify {
+ interactor.onSortingStrategyChanged(SortingStrategy.Alphabetically)
+ }
+
+ lastUsed.onClick()
+ verify {
+ interactor.onSortingStrategyChanged(
+ SortingStrategy.LastUsed,
+ )
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsStorageControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsStorageControllerTest.kt
new file mode 100644
index 0000000000..b8bbd7affa
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsStorageControllerTest.kt
@@ -0,0 +1,346 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.logins
+
+import androidx.navigation.NavController
+import androidx.navigation.NavDestination
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import mozilla.components.concept.storage.EncryptedLogin
+import mozilla.components.concept.storage.Login
+import mozilla.components.concept.storage.LoginEntry
+import mozilla.components.service.sync.logins.InvalidRecordException
+import mozilla.components.service.sync.logins.SyncableLoginsStorage
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.ext.directionsEq
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
+import org.mozilla.fenix.settings.logins.fragment.EditLoginFragmentDirections
+import org.mozilla.fenix.utils.ClipboardHandler
+
+@RunWith(FenixRobolectricTestRunner::class)
+class SavedLoginsStorageControllerTest {
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val ioDispatcher = coroutinesTestRule.testDispatcher
+ private val scope = coroutinesTestRule.scope
+
+ private val passwordsStorage: SyncableLoginsStorage = mockk(relaxed = true)
+ private lateinit var controller: SavedLoginsStorageController
+ private val navController: NavController = mockk(relaxed = true)
+ private val loginsFragmentStore: LoginsFragmentStore = mockk(relaxed = true)
+ private val clipboardHandler: ClipboardHandler = mockk(relaxed = true)
+ private val loginMock: Login = mockk(relaxed = true)
+
+ @Before
+ fun setup() {
+ every { navController.currentDestination } returns NavDestination("").apply {
+ id = R.id.loginDetailFragment
+ }
+ coEvery { passwordsStorage.get(any()) } returns loginMock
+ every { loginsFragmentStore.dispatch(any()) } returns mockk()
+
+ controller = SavedLoginsStorageController(
+ passwordsStorage = passwordsStorage,
+ lifecycleScope = scope,
+ navController = navController,
+ loginsFragmentStore = loginsFragmentStore,
+ ioDispatcher = ioDispatcher,
+ clipboardHandler = clipboardHandler,
+ )
+ }
+
+ @Test
+ fun `WHEN a login is deleted, THEN navigate back to the previous page`() = runTestOnMain {
+ val loginId = "id"
+ coEvery { passwordsStorage.delete(any()) } returns true
+ controller.delete(loginId)
+
+ coVerify {
+ passwordsStorage.delete(loginId)
+ loginsFragmentStore.dispatch(LoginsAction.DeleteLogin(loginId))
+ navController.popBackStack(R.id.savedLoginsFragment, false)
+ }
+ }
+
+ @Test
+ fun `WHEN fetching the login list, THEN update the state in the store`() = runTestOnMain {
+ val login = Login(
+ guid = "id",
+ origin = "https://www.test.co.gov.org",
+ username = "user123",
+ password = "securePassword1",
+ httpRealm = "httpRealm",
+ formActionOrigin = "",
+ )
+ coEvery { passwordsStorage.get("id") } returns login
+
+ controller.fetchLoginDetails(login.guid)
+
+ val expectedLogin = login.mapToSavedLogin()
+
+ coVerify {
+ passwordsStorage.get("id")
+ loginsFragmentStore.dispatch(
+ LoginsAction.UpdateCurrentLogin(
+ expectedLogin,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN saving an update to an item, THEN navigate to login detail view`() = runTestOnMain {
+ val oldLogin = Login(
+ guid = "id",
+ origin = "https://www.test.co.gov.org",
+ username = "user123",
+ password = "securePassword1",
+ httpRealm = "httpRealm",
+ formActionOrigin = "",
+ )
+ val oldLoginEncrypted = EncryptedLogin(
+ guid = "id",
+ origin = "https://www.test.co.gov.org",
+ httpRealm = "httpRealm",
+ formActionOrigin = "",
+ secFields = "fake-encrypted-data",
+ )
+ val newLogin = Login(
+ guid = "id",
+ origin = "https://www.test.co.gov.org",
+ username = "newUsername",
+ password = "newPassword",
+ httpRealm = "httpRealm",
+ formActionOrigin = "",
+ )
+
+ coEvery { passwordsStorage.get(any()) } returns oldLogin
+ coEvery { passwordsStorage.update(any(), any()) } returns oldLoginEncrypted
+ coEvery { passwordsStorage.decryptLogin(any()) } returns newLogin
+
+ controller.save(oldLogin.guid, "newUsername", "newPassword")
+
+ val directions =
+ EditLoginFragmentDirections.actionEditLoginFragmentToLoginDetailFragment(
+ oldLogin.guid,
+ )
+
+ val expectedNewLogin = newLogin.mapToSavedLogin()
+
+ coVerify {
+ passwordsStorage.get(oldLogin.guid)
+ passwordsStorage.update(newLogin.guid, newLogin.toEntry())
+ loginsFragmentStore.dispatch(
+ LoginsAction.UpdateLogin(
+ newLogin.guid,
+ expectedNewLogin,
+ ),
+ )
+ navController.navigate(directionsEq(directions))
+ }
+ }
+
+ @Test
+ fun `WHEN login dupe is found for save, THEN update duplicate in the store`() = runTestOnMain {
+ val login = Login(
+ guid = "id",
+ origin = "https://www.test.co.gov.org",
+ username = "user123",
+ password = "securePassword1",
+ httpRealm = "httpRealm",
+ formActionOrigin = null,
+ )
+
+ val login2 = Login(
+ guid = "id2",
+ origin = "https://www.test.co.gov.org",
+ username = "user1234",
+ password = "securePassword1",
+ httpRealm = "httpRealm",
+ formActionOrigin = null,
+ )
+
+ coEvery { passwordsStorage.get(any()) } returns login
+ coEvery {
+ passwordsStorage.findLoginToUpdate(any())
+ } returns login2
+
+ // Simulate calling findDuplicateForSave after the user set the username field to the login2's username
+ controller.findDuplicateForSave(login.guid, login2.username, login.password)
+
+ coVerify {
+ passwordsStorage.get(login.guid)
+ passwordsStorage.findLoginToUpdate(
+ LoginEntry(
+ origin = login.origin,
+ httpRealm = login.httpRealm,
+ formActionOrigin = login.formActionOrigin,
+ username = login2.username,
+ password = login.password,
+ ),
+ )
+ loginsFragmentStore.dispatch(
+ LoginsAction.DuplicateLogin(login2.mapToSavedLogin()),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN login dupe is not found for save, THEN update duplicate in the store`() = runTestOnMain {
+ val login = Login(
+ guid = "id",
+ origin = "https://www.test.co.gov.org",
+ username = "user123",
+ password = "securePassword1",
+ httpRealm = "httpRealm",
+ formActionOrigin = null,
+ )
+
+ coEvery { passwordsStorage.get(any()) } returns login
+ coEvery {
+ passwordsStorage.findLoginToUpdate(any())
+ } returns null
+
+ // Simulate calling findDuplicateForSave after the user set the username field to a new value
+ controller.findDuplicateForSave(login.guid, "new-username", login.password)
+
+ coVerify {
+ passwordsStorage.get(login.guid)
+ passwordsStorage.findLoginToUpdate(
+ LoginEntry(
+ origin = login.origin,
+ httpRealm = login.httpRealm,
+ formActionOrigin = login.formActionOrigin,
+ username = "new-username",
+ password = login.password,
+ ),
+ )
+ loginsFragmentStore.dispatch(
+ LoginsAction.DuplicateLogin(null),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN login dupe is found for add, THEN update duplicate in the store`() = runTestOnMain {
+ val login = Login(
+ guid = "id",
+ origin = "https://www.test.co.gov.org",
+ username = "user1234",
+ password = "securePassword1",
+ httpRealm = "httpRealm",
+ formActionOrigin = null,
+ )
+
+ coEvery {
+ passwordsStorage.findLoginToUpdate(any())
+ } returns login
+
+ // Simulate calling findDuplicateForAdd after the user set the origin/username fields to match login
+ controller.findDuplicateForAdd(login.origin, login.username, "new-password")
+
+ coVerify {
+ passwordsStorage.findLoginToUpdate(
+ LoginEntry(
+ origin = login.origin,
+ httpRealm = login.origin,
+ formActionOrigin = null,
+ username = login.username,
+ password = "new-password",
+ ),
+ )
+ loginsFragmentStore.dispatch(
+ LoginsAction.DuplicateLogin(login.mapToSavedLogin()),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN login dupe is not found for add, THEN update duplicate in the store`() = runTestOnMain {
+ coEvery {
+ passwordsStorage.findLoginToUpdate(any())
+ } returns null
+
+ // Simulate calling findDuplicateForAdd after the user set the origin/username field to new values
+ val origin = "https://new-origin.example.com"
+ controller.findDuplicateForAdd(origin, "username", "password")
+
+ coVerify {
+ passwordsStorage.findLoginToUpdate(
+ LoginEntry(
+ origin = origin,
+ httpRealm = origin,
+ formActionOrigin = null,
+ username = "username",
+ password = "password",
+ ),
+ )
+ loginsFragmentStore.dispatch(
+ LoginsAction.DuplicateLogin(null),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN findLoginToUpdate throws THEN update duplicate in the store`() = runTestOnMain {
+ coEvery {
+ passwordsStorage.findLoginToUpdate(any())
+ } throws InvalidRecordException("InvalidOrigin")
+
+ // Simulate calling findDuplicateForAdd with an invalid origin
+ val origin = "https://"
+ controller.findDuplicateForAdd(origin, "username", "password")
+
+ coVerify {
+ passwordsStorage.findLoginToUpdate(
+ LoginEntry(
+ origin = origin,
+ httpRealm = origin,
+ formActionOrigin = null,
+ username = "username",
+ password = "password",
+ ),
+ )
+ loginsFragmentStore.dispatch(
+ LoginsAction.DuplicateLogin(null),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN dupe checking THEN always use a non-blank password`() = runTestOnMain {
+ // If the user hasn't entered a password yet, we should use a dummy
+ // password to send a valid login entry to findLoginToUpdate()
+
+ coEvery {
+ passwordsStorage.findLoginToUpdate(any())
+ } throws InvalidRecordException("InvalidOrigin")
+
+ // Simulate calling findDuplicateForAdd with an invalid origin
+ val origin = "https://example.com/"
+ controller.findDuplicateForAdd(origin, "username", "")
+
+ coVerify {
+ passwordsStorage.findLoginToUpdate(
+ LoginEntry(
+ origin = origin,
+ httpRealm = origin,
+ formActionOrigin = null,
+ username = "username",
+ password = "password",
+ ),
+ )
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/SyncPreferenceViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/SyncPreferenceViewTest.kt
new file mode 100644
index 0000000000..a7569aaba8
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/SyncPreferenceViewTest.kt
@@ -0,0 +1,210 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.logins
+
+import android.content.Context
+import androidx.lifecycle.LifecycleOwner
+import androidx.navigation.NavController
+import androidx.preference.Preference
+import io.mockk.CapturingSlot
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkConstructor
+import io.mockk.slot
+import io.mockk.unmockkConstructor
+import io.mockk.verify
+import mozilla.components.concept.sync.AccountObserver
+import mozilla.components.service.fxa.SyncEngine
+import mozilla.components.service.fxa.manager.FxaAccountManager
+import mozilla.components.service.fxa.manager.SyncEnginesStorage
+import mozilla.components.support.test.any
+import org.junit.After
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.R
+import org.mozilla.fenix.components.accounts.FenixFxAEntryPoint
+import org.mozilla.fenix.settings.SyncPreference
+import org.mozilla.fenix.settings.SyncPreferenceView
+import org.mozilla.fenix.settings.logins.fragment.SavedLoginsAuthFragmentDirections
+
+class SyncPreferenceViewTest {
+
+ @MockK private lateinit var syncPreference: SyncPreference
+
+ @MockK private lateinit var lifecycleOwner: LifecycleOwner
+
+ @MockK private lateinit var accountManager: FxaAccountManager
+
+ @MockK(relaxed = true)
+ private lateinit var navController: NavController
+ private lateinit var accountObserver: CapturingSlot<AccountObserver>
+ private lateinit var preferenceChangeListener: CapturingSlot<Preference.OnPreferenceChangeListener>
+ private lateinit var widgetVisibilitySlot: CapturingSlot<Boolean>
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ mockkConstructor(SyncEnginesStorage::class)
+
+ accountObserver = slot()
+ preferenceChangeListener = slot()
+ widgetVisibilitySlot = slot()
+
+ val context = mockk<Context> {
+ every { getString(R.string.pref_key_credit_cards_sync_cards_across_devices) } returns "pref_key_credit_cards_sync_cards_across_devices"
+ every { getString(R.string.preferences_credit_cards_sync_cards_across_devices) } returns "Sync cards across devices"
+ every { getString(R.string.preferences_credit_cards_sync_cards) } returns "Sync cards"
+
+ every { getString(R.string.pref_key_sync_logins) } returns "pref_key_sync_logins"
+ every { getString(R.string.preferences_passwords_sync_logins_2) } returns "Sync passwords"
+ every { getString(R.string.preferences_passwords_sync_logins_across_devices_2) } returns "Sync passwords across devices"
+ }
+
+ syncPreference = mockk {
+ every { isSwitchWidgetVisible = any() } just Runs
+ every { key } returns "pref_key_sync_logins"
+ every { isChecked = any() } just Runs
+ every { title = any() } just Runs
+ }
+
+ every { syncPreference.title = any() } just Runs
+ every { syncPreference.onPreferenceChangeListener = capture(preferenceChangeListener) } just Runs
+ every { syncPreference.context } returns context
+ every { accountManager.register(capture(accountObserver), owner = lifecycleOwner) } just Runs
+ every { anyConstructed<SyncEnginesStorage>().getStatus() } returns emptyMap()
+ }
+
+ @After
+ fun teardown() {
+ unmockkConstructor(SyncEnginesStorage::class)
+ }
+
+ @Test
+ fun `needs reauth ui on init`() {
+ every { accountManager.authenticatedAccount() } returns mockk()
+ every { accountManager.accountNeedsReauth() } returns true
+
+ createView()
+
+ verify { syncPreference.isSwitchWidgetVisible = false }
+ verify { syncPreference.title = notLoggedInTitle }
+ assertFalse(preferenceChangeListener.captured.onPreferenceChange(syncPreference, any()))
+ verify {
+ navController.navigate(
+ SavedLoginsAuthFragmentDirections.actionGlobalAccountProblemFragment(entrypoint = FenixFxAEntryPoint.SavedLogins),
+ )
+ }
+ }
+
+ @Test
+ fun `needs reauth ui on init even if null account`() {
+ every { accountManager.authenticatedAccount() } returns null
+ every { accountManager.accountNeedsReauth() } returns true
+
+ createView()
+
+ verify { syncPreference.isSwitchWidgetVisible = false }
+ verify { syncPreference.title = notLoggedInTitle }
+ assertFalse(preferenceChangeListener.captured.onPreferenceChange(syncPreference, any()))
+ verify {
+ navController.navigate(
+ SavedLoginsAuthFragmentDirections.actionGlobalAccountProblemFragment(
+ entrypoint = FenixFxAEntryPoint.SavedLogins,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `needs login if account does not exist`() {
+ every { accountManager.authenticatedAccount() } returns null
+ every { accountManager.accountNeedsReauth() } returns false
+
+ createView()
+
+ verify { syncPreference.isSwitchWidgetVisible = false }
+ verify { syncPreference.title = notLoggedInTitle }
+ assertFalse(preferenceChangeListener.captured.onPreferenceChange(syncPreference, any()))
+ verify {
+ navController.navigate(
+ SavedLoginsAuthFragmentDirections.actionSavedLoginsAuthFragmentToTurnOnSyncFragment(
+ entrypoint = FenixFxAEntryPoint.SavedLogins,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN LoginScreen and syncLogins true WHEN updateSyncPreferenceStatus THEN setStatus false`() {
+ every { accountManager.authenticatedAccount() } returns mockk()
+ every { accountManager.accountNeedsReauth() } returns false
+ every { anyConstructed<SyncEnginesStorage>().getStatus() } returns mapOf(
+ SyncEngine.Passwords to true,
+ )
+ every { anyConstructed<SyncEnginesStorage>().setStatus(any(), any()) } just Runs
+ every { syncPreference.setSwitchCheckedState(any()) } just Runs
+
+ createView()
+
+ verify { syncPreference.isSwitchWidgetVisible = true }
+ verify { syncPreference.isChecked = true }
+ verify { syncPreference.title = loggedInTitle }
+ assertTrue(preferenceChangeListener.captured.onPreferenceChange(syncPreference, false))
+ verify { anyConstructed<SyncEnginesStorage>().setStatus(any(), false) }
+ }
+
+ @Test
+ fun `GIVEN LoginScreen and syncLogins false WHEN updateSyncPreferenceStatus THEN setStatus true`() {
+ every { accountManager.authenticatedAccount() } returns mockk()
+ every { accountManager.accountNeedsReauth() } returns false
+ every { anyConstructed<SyncEnginesStorage>().getStatus() } returns mapOf(
+ SyncEngine.Passwords to false,
+ )
+ every { anyConstructed<SyncEnginesStorage>().setStatus(any(), any()) } just Runs
+ every { syncPreference.setSwitchCheckedState(any()) } just Runs
+
+ createView()
+
+ verify { syncPreference.isSwitchWidgetVisible = true }
+ verify { syncPreference.isChecked = false }
+ verify { syncPreference.title = loggedInTitle }
+ assertTrue(preferenceChangeListener.captured.onPreferenceChange(syncPreference, true))
+ verify { anyConstructed<SyncEnginesStorage>().setStatus(any(), true) }
+ }
+
+ private fun createView() = SyncPreferenceView(
+ syncPreference = syncPreference,
+ lifecycleOwner = lifecycleOwner,
+ accountManager = accountManager,
+ syncEngine = SyncEngine.Passwords,
+ loggedOffTitle = notLoggedInTitle,
+ loggedInTitle = loggedInTitle,
+ onSyncSignInClicked = {
+ val directions =
+ SavedLoginsAuthFragmentDirections.actionSavedLoginsAuthFragmentToTurnOnSyncFragment(
+ entrypoint = FenixFxAEntryPoint.SavedLogins,
+ )
+ navController.navigate(directions)
+ },
+ onReconnectClicked = {
+ val directions =
+ SavedLoginsAuthFragmentDirections.actionGlobalAccountProblemFragment(
+ entrypoint = FenixFxAEntryPoint.SavedLogins,
+ )
+ navController.navigate(directions)
+ },
+ )
+
+ companion object {
+ const val notLoggedInTitle: String = "Sync passwords across devices"
+ const val loggedInTitle: String = "Sync passwords"
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/AutoplayValueTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/AutoplayValueTest.kt
new file mode 100644
index 0000000000..e76b72e80a
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/AutoplayValueTest.kt
@@ -0,0 +1,385 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.quicksettings
+
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import mozilla.components.concept.engine.permission.SitePermissions
+import mozilla.components.concept.engine.permission.SitePermissions.AutoplayStatus
+import mozilla.components.feature.sitepermissions.SitePermissionsRules
+import mozilla.components.feature.sitepermissions.SitePermissionsRules.Action
+import mozilla.components.feature.sitepermissions.SitePermissionsRules.AutoplayAction
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class AutoplayValueTest {
+ @MockK(relaxed = true)
+ private lateinit var settings: Settings
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ }
+
+ @Test
+ fun `AllowAll - isSelected`() {
+ var rules = getRules().copy(
+ autoplayAudible = AutoplayAction.ALLOWED,
+ autoplayInaudible = AutoplayAction.ALLOWED,
+ )
+
+ var value = AutoplayValue.AllowAll(
+ label = "label",
+ rules = rules,
+ sitePermission = null,
+ )
+
+ assertTrue(value.isSelected())
+
+ rules = rules.copy(
+ autoplayAudible = AutoplayAction.BLOCKED,
+ )
+
+ value = AutoplayValue.AllowAll(
+ label = "label",
+ rules = rules,
+ sitePermission = null,
+ )
+
+ assertFalse(value.isSelected())
+
+ value = AutoplayValue.AllowAll(
+ label = "label",
+ rules = rules,
+ sitePermission = SitePermissions(
+ origin = "",
+ savedAt = 0L,
+ autoplayAudible = AutoplayStatus.ALLOWED,
+ autoplayInaudible = AutoplayStatus.ALLOWED,
+ ),
+ )
+
+ assertTrue(value.isSelected())
+
+ value = AutoplayValue.AllowAll(
+ label = "label",
+ rules = rules,
+ sitePermission = SitePermissions(
+ origin = "",
+ savedAt = 0L,
+ autoplayAudible = AutoplayStatus.BLOCKED,
+ autoplayInaudible = AutoplayStatus.BLOCKED,
+ ),
+ )
+
+ assertFalse(value.isSelected())
+ }
+
+ @Test
+ fun `BlockAll - isSelected`() {
+ var rules = getRules().copy(
+ autoplayAudible = AutoplayAction.BLOCKED,
+ autoplayInaudible = AutoplayAction.BLOCKED,
+ )
+
+ var value = AutoplayValue.BlockAll(
+ label = "label",
+ rules = rules,
+ sitePermission = null,
+ )
+
+ assertTrue(value.isSelected())
+
+ rules = rules.copy(
+ autoplayAudible = AutoplayAction.BLOCKED,
+ autoplayInaudible = AutoplayAction.ALLOWED,
+ )
+
+ value = AutoplayValue.BlockAll(
+ label = "label",
+ rules = rules,
+ sitePermission = null,
+ )
+
+ assertFalse(value.isSelected())
+
+ value = AutoplayValue.BlockAll(
+ label = "label",
+ rules = rules,
+ sitePermission = SitePermissions(
+ origin = "",
+ savedAt = 0L,
+ autoplayAudible = AutoplayStatus.BLOCKED,
+ autoplayInaudible = AutoplayStatus.BLOCKED,
+ ),
+ )
+
+ assertTrue(value.isSelected())
+
+ value = AutoplayValue.BlockAll(
+ label = "label",
+ rules = rules,
+ sitePermission = SitePermissions(
+ origin = "",
+ savedAt = 0L,
+ autoplayAudible = AutoplayStatus.ALLOWED,
+ autoplayInaudible = AutoplayStatus.BLOCKED,
+ ),
+ )
+
+ assertFalse(value.isSelected())
+ }
+
+ @Test
+ fun `BlockAudible - isSelected`() {
+ var rules = getRules().copy(
+ autoplayAudible = AutoplayAction.BLOCKED,
+ autoplayInaudible = AutoplayAction.ALLOWED,
+ )
+
+ var value = AutoplayValue.BlockAudible(
+ label = "label",
+ rules = rules,
+ sitePermission = null,
+ )
+
+ assertTrue(value.isSelected())
+
+ rules = rules.copy(
+ autoplayAudible = AutoplayAction.BLOCKED,
+ autoplayInaudible = AutoplayAction.BLOCKED,
+ )
+
+ value = AutoplayValue.BlockAudible(
+ label = "label",
+ rules = rules,
+ sitePermission = null,
+ )
+
+ assertFalse(value.isSelected())
+
+ value = AutoplayValue.BlockAudible(
+ label = "label",
+ rules = rules,
+ sitePermission = SitePermissions(
+ origin = "",
+ savedAt = 0L,
+ autoplayAudible = AutoplayStatus.BLOCKED,
+ autoplayInaudible = AutoplayStatus.ALLOWED,
+ ),
+ )
+
+ assertTrue(value.isSelected())
+
+ value = AutoplayValue.BlockAudible(
+ label = "label",
+ rules = rules,
+ sitePermission = SitePermissions(
+ origin = "",
+ savedAt = 0L,
+ autoplayAudible = AutoplayStatus.ALLOWED,
+ autoplayInaudible = AutoplayStatus.ALLOWED,
+ ),
+ )
+
+ assertFalse(value.isSelected())
+ }
+
+ @Test
+ fun `AllowAll - createSitePermissionsFromCustomRules`() {
+ val rules = getRules().copy(
+ autoplayAudible = AutoplayAction.BLOCKED,
+ autoplayInaudible = AutoplayAction.BLOCKED,
+ )
+
+ every { settings.getSitePermissionsCustomSettingsRules() } returns rules
+
+ val value = AutoplayValue.AllowAll(
+ label = "label",
+ rules = rules,
+ sitePermission = null,
+ )
+
+ val result = value.createSitePermissionsFromCustomRules("mozilla.org", settings)
+
+ assertEquals(AutoplayStatus.ALLOWED, result.autoplayAudible)
+ assertEquals(AutoplayStatus.ALLOWED, result.autoplayInaudible)
+ assertEquals(rules.camera.toStatus(), result.camera)
+ assertEquals(rules.location.toStatus(), result.location)
+ assertEquals(rules.microphone.toStatus(), result.microphone)
+ assertEquals(rules.notification.toStatus(), result.notification)
+ assertEquals(rules.persistentStorage.toStatus(), result.localStorage)
+ assertEquals(rules.crossOriginStorageAccess.toStatus(), result.crossOriginStorageAccess)
+ assertEquals(rules.mediaKeySystemAccess.toStatus(), result.mediaKeySystemAccess)
+ }
+
+ @Test
+ fun `BlockAll - createSitePermissionsFromCustomRules`() {
+ val rules = getRules().copy(
+ autoplayAudible = AutoplayAction.ALLOWED,
+ autoplayInaudible = AutoplayAction.ALLOWED,
+ )
+
+ every { settings.getSitePermissionsCustomSettingsRules() } returns rules
+
+ val value = AutoplayValue.BlockAll(
+ label = "label",
+ rules = rules,
+ sitePermission = null,
+ )
+
+ val result = value.createSitePermissionsFromCustomRules("mozilla.org", settings)
+
+ assertEquals(AutoplayStatus.BLOCKED, result.autoplayAudible)
+ assertEquals(AutoplayStatus.BLOCKED, result.autoplayInaudible)
+ assertEquals(rules.camera.toStatus(), result.camera)
+ assertEquals(rules.location.toStatus(), result.location)
+ assertEquals(rules.microphone.toStatus(), result.microphone)
+ assertEquals(rules.notification.toStatus(), result.notification)
+ assertEquals(rules.persistentStorage.toStatus(), result.localStorage)
+ assertEquals(rules.crossOriginStorageAccess.toStatus(), result.crossOriginStorageAccess)
+ assertEquals(rules.mediaKeySystemAccess.toStatus(), result.mediaKeySystemAccess)
+ }
+
+ @Test
+ fun `BlockAudible - createSitePermissionsFromCustomRules`() {
+ val rules = getRules().copy(
+ autoplayAudible = AutoplayAction.ALLOWED,
+ autoplayInaudible = AutoplayAction.ALLOWED,
+ )
+
+ every { settings.getSitePermissionsCustomSettingsRules() } returns rules
+
+ val value = AutoplayValue.BlockAudible(
+ label = "label",
+ rules = rules,
+ sitePermission = null,
+ )
+
+ val result = value.createSitePermissionsFromCustomRules("mozilla.org", settings)
+
+ assertEquals(AutoplayStatus.BLOCKED, result.autoplayAudible)
+ assertEquals(AutoplayStatus.ALLOWED, result.autoplayInaudible)
+ assertEquals(rules.camera.toStatus(), result.camera)
+ assertEquals(rules.location.toStatus(), result.location)
+ assertEquals(rules.microphone.toStatus(), result.microphone)
+ assertEquals(rules.notification.toStatus(), result.notification)
+ assertEquals(rules.persistentStorage.toStatus(), result.localStorage)
+ assertEquals(rules.crossOriginStorageAccess.toStatus(), result.crossOriginStorageAccess)
+ assertEquals(rules.mediaKeySystemAccess.toStatus(), result.mediaKeySystemAccess)
+ }
+
+ @Test
+ fun `AllowAll - updateSitePermissions`() {
+ val sitePermissions = SitePermissions(
+ origin = "origin",
+ savedAt = 0L,
+ autoplayAudible = AutoplayStatus.BLOCKED,
+ autoplayInaudible = AutoplayStatus.BLOCKED,
+ )
+
+ val value = AutoplayValue.AllowAll(
+ label = "label",
+ rules = mockk(),
+ sitePermission = null,
+ )
+
+ val result = value.updateSitePermissions(sitePermissions)
+
+ assertEquals(AutoplayStatus.ALLOWED, result.autoplayAudible)
+ assertEquals(AutoplayStatus.ALLOWED, result.autoplayInaudible)
+ assertEquals(sitePermissions.camera, result.camera)
+ assertEquals(sitePermissions.location, result.location)
+ assertEquals(sitePermissions.microphone, result.microphone)
+ assertEquals(sitePermissions.notification, result.notification)
+ assertEquals(sitePermissions.localStorage, result.localStorage)
+ assertEquals(sitePermissions.mediaKeySystemAccess, result.mediaKeySystemAccess)
+ }
+
+ @Test
+ fun `BlockAll - updateSitePermissions`() {
+ val sitePermissions = SitePermissions(
+ origin = "origin",
+ savedAt = 0L,
+ autoplayAudible = AutoplayStatus.ALLOWED,
+ autoplayInaudible = AutoplayStatus.ALLOWED,
+ )
+
+ val value = AutoplayValue.BlockAll(
+ label = "label",
+ rules = mockk(),
+ sitePermission = null,
+ )
+
+ val result = value.updateSitePermissions(sitePermissions)
+
+ assertEquals(AutoplayStatus.BLOCKED, result.autoplayAudible)
+ assertEquals(AutoplayStatus.BLOCKED, result.autoplayInaudible)
+ assertEquals(sitePermissions.camera, result.camera)
+ assertEquals(sitePermissions.location, result.location)
+ assertEquals(sitePermissions.microphone, result.microphone)
+ assertEquals(sitePermissions.notification, result.notification)
+ assertEquals(sitePermissions.localStorage, result.localStorage)
+ assertEquals(sitePermissions.mediaKeySystemAccess, result.mediaKeySystemAccess)
+ }
+
+ @Test
+ fun `BlockAudible - updateSitePermissions`() {
+ val sitePermissions = SitePermissions(
+ origin = "origin",
+ savedAt = 0L,
+ autoplayAudible = AutoplayStatus.ALLOWED,
+ autoplayInaudible = AutoplayStatus.BLOCKED,
+ )
+
+ val value = AutoplayValue.BlockAudible(
+ label = "label",
+ rules = mockk(),
+ sitePermission = null,
+ )
+
+ val result = value.updateSitePermissions(sitePermissions)
+
+ assertEquals(AutoplayStatus.BLOCKED, result.autoplayAudible)
+ assertEquals(AutoplayStatus.ALLOWED, result.autoplayInaudible)
+ assertEquals(sitePermissions.camera, result.camera)
+ assertEquals(sitePermissions.location, result.location)
+ assertEquals(sitePermissions.microphone, result.microphone)
+ assertEquals(sitePermissions.notification, result.notification)
+ assertEquals(sitePermissions.localStorage, result.localStorage)
+ assertEquals(sitePermissions.mediaKeySystemAccess, result.mediaKeySystemAccess)
+ }
+
+ @Test
+ fun `values - contains the right values`() {
+ val values = AutoplayValue.values(testContext, settings, null)
+
+ assertTrue(values.any { it is AutoplayValue.AllowAll })
+ assertTrue(values.any { it is AutoplayValue.BlockAll })
+ assertTrue(values.any { it is AutoplayValue.BlockAudible })
+ }
+
+ private fun getRules() = SitePermissionsRules(
+ camera = Action.ASK_TO_ALLOW,
+ location = Action.ASK_TO_ALLOW,
+ microphone = Action.ASK_TO_ALLOW,
+ notification = Action.ASK_TO_ALLOW,
+ autoplayAudible = AutoplayAction.BLOCKED,
+ autoplayInaudible = AutoplayAction.BLOCKED,
+ persistentStorage = Action.ASK_TO_ALLOW,
+ mediaKeySystemAccess = Action.ASK_TO_ALLOW,
+ crossOriginStorageAccess = Action.ASK_TO_ALLOW,
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/ClearSiteDataViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/ClearSiteDataViewTest.kt
new file mode 100644
index 0000000000..ba9529d576
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/ClearSiteDataViewTest.kt
@@ -0,0 +1,65 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.quicksettings
+
+import android.view.View
+import android.widget.FrameLayout
+import androidx.navigation.NavController
+import io.mockk.MockKAnnotations
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verifyOrder
+import kotlinx.coroutines.test.TestScope
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.databinding.QuicksettingsClearSiteDataBinding
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class ClearSiteDataViewTest {
+ private lateinit var view: ClearSiteDataView
+ private lateinit var binding: QuicksettingsClearSiteDataBinding
+ private lateinit var interactor: ClearSiteDataViewInteractor
+ private lateinit var navController: NavController
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ interactor = mockk(relaxed = true)
+ navController = mockk(relaxed = true)
+ view = spyk(
+ ClearSiteDataView(
+ testContext,
+ TestScope(),
+ FrameLayout(testContext),
+ View(testContext),
+ interactor,
+ navController,
+ ),
+ )
+ binding = view.binding
+ }
+
+ @Test
+ fun `clear site`() {
+ val state = WebsiteInfoState(
+ websiteUrl = "https://developers.mozilla.org",
+ websiteTitle = "Mozilla",
+ websiteSecurityUiValues = WebsiteSecurityUiValues.SECURE,
+ certificateName = "Certificate",
+ )
+
+ view.update(state)
+
+ binding.clearSiteData.callOnClick()
+
+ verifyOrder {
+ view.askToClear()
+ navController.popBackStack()
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/ConnectionDetailsInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/ConnectionDetailsInteractorTest.kt
new file mode 100644
index 0000000000..544367ed18
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/ConnectionDetailsInteractorTest.kt
@@ -0,0 +1,31 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.quicksettings
+
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+
+class ConnectionDetailsInteractorTest {
+
+ private lateinit var controller: ConnectionDetailsController
+ private lateinit var interactor: ConnectionDetailsInteractor
+
+ @Before
+ fun setUp() {
+ controller = mockk(relaxed = true)
+ interactor = ConnectionDetailsInteractor(controller)
+ }
+
+ @Test
+ fun `WHEN onBackPressed is called THEN delegate the controller`() {
+ interactor.onBackPressed()
+
+ verify {
+ controller.handleBackPressed()
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/ConnectionDetailsViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/ConnectionDetailsViewTest.kt
new file mode 100644
index 0000000000..0ac8e939df
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/ConnectionDetailsViewTest.kt
@@ -0,0 +1,103 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.quicksettings
+
+import android.widget.FrameLayout
+import androidx.core.view.isVisible
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.browser.icons.BrowserIcons
+import mozilla.components.browser.icons.IconRequest
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.databinding.ConnectionDetailsWebsiteInfoBinding
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class ConnectionDetailsViewTest {
+
+ private lateinit var view: ConnectionDetailsView
+ private lateinit var icons: BrowserIcons
+ private lateinit var binding: ConnectionDetailsWebsiteInfoBinding
+ private lateinit var interactor: WebSiteInfoInteractor
+
+ @Before
+ fun setup() {
+ icons = mockk(relaxed = true)
+ interactor = mockk(relaxed = true)
+ view = spyk(ConnectionDetailsView(FrameLayout(testContext), icons, interactor))
+ binding = view.binding
+ every { icons.loadIntoView(any(), any()) } returns mockk()
+ }
+
+ @Test
+ fun `WHEN updating THEN bind url and title`() {
+ val websiteUrl = "https://mozilla.org"
+
+ view.update(
+ WebsiteInfoState(
+ websiteUrl = websiteUrl,
+ websiteTitle = "Mozilla",
+ websiteSecurityUiValues = WebsiteSecurityUiValues.SECURE,
+ certificateName = "",
+ ),
+ )
+
+ verify { icons.loadIntoView(binding.faviconImage, IconRequest(websiteUrl)) }
+
+ assertEquals("https://mozilla.org", binding.url.text)
+ assertEquals("Connection is secure", binding.securityInfo.text)
+ }
+
+ @Test
+ fun `WHEN updating THEN bind certificate`() {
+ view.update(
+ WebsiteInfoState(
+ websiteUrl = "https://mozilla.org",
+ websiteTitle = "Mozilla",
+ websiteSecurityUiValues = WebsiteSecurityUiValues.INSECURE,
+ certificateName = "Certificate",
+ ),
+ )
+
+ assertEquals("Connection is not secure", binding.securityInfo.text)
+ }
+
+ @Test
+ fun `WHEN updating THEN bind the certificate, title and back button listener`() {
+ view.update(
+ WebsiteInfoState(
+ websiteUrl = "https://mozilla.org",
+ websiteTitle = "Mozilla",
+ websiteSecurityUiValues = WebsiteSecurityUiValues.INSECURE,
+ certificateName = "Certificate",
+ ),
+ )
+
+ verify {
+ view.bindCertificateName("Certificate")
+ view.bindTitle("Mozilla")
+ view.bindBackButtonListener()
+ }
+ }
+
+ @Test
+ fun `WHEN title is empty THEN the title should be gone`() {
+ view.bindTitle("")
+
+ assertFalse(binding.titleContainer.isVisible)
+
+ view.bindTitle("Title")
+
+ assertTrue(binding.titleContainer.isVisible)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/DefaultConnectionDetailsControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/DefaultConnectionDetailsControllerTest.kt
new file mode 100644
index 0000000000..b9f7daf8b4
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/DefaultConnectionDetailsControllerTest.kt
@@ -0,0 +1,102 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.quicksettings
+
+import android.content.Context
+import androidx.fragment.app.Fragment
+import androidx.navigation.NavController
+import androidx.navigation.NavDirections
+import io.mockk.MockKAnnotations
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.spyk
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.concept.engine.cookiehandling.CookieBannersStorage
+import mozilla.components.concept.engine.permission.SitePermissions
+import mozilla.components.feature.session.TrackingProtectionUseCases
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class DefaultConnectionDetailsControllerTest {
+
+ private lateinit var context: Context
+
+ @MockK(relaxed = true)
+ private lateinit var navController: NavController
+
+ @MockK(relaxed = true)
+ private lateinit var fragment: Fragment
+
+ @MockK(relaxed = true)
+ private lateinit var sitePermissions: SitePermissions
+
+ @MockK(relaxed = true)
+ private lateinit var cookieBannersStorage: CookieBannersStorage
+
+ private lateinit var controller: DefaultConnectionDetailsController
+
+ private lateinit var tab: TabSessionState
+
+ private var gravity = 54
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val scope = coroutinesTestRule.scope
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+ val trackingProtectionUseCases: TrackingProtectionUseCases = mockk(relaxed = true)
+ context = spyk(testContext)
+ tab = createTab("https://mozilla.org")
+ controller = DefaultConnectionDetailsController(
+ fragment = fragment,
+ context = context,
+ ioScope = scope,
+ cookieBannersStorage = cookieBannersStorage,
+ navController = { navController },
+ sitePermissions = sitePermissions,
+ gravity = gravity,
+ getCurrentTab = { tab },
+ )
+
+ every { fragment.context } returns context
+ every { context.components.useCases.trackingProtectionUseCases } returns trackingProtectionUseCases
+
+ val onComplete = slot<(Boolean) -> Unit>()
+ every {
+ trackingProtectionUseCases.containsException.invoke(
+ any(),
+ capture(onComplete),
+ )
+ }.answers { onComplete.captured.invoke(true) }
+ }
+
+ @Test
+ fun `WHEN handleBackPressed is called THEN should call popBackStack and navigate`() = runTestOnMain {
+ every { context.settings().shouldUseCookieBannerPrivateMode } returns false
+
+ controller.handleBackPressed()
+
+ coVerify {
+ navController.popBackStack()
+
+ navController.navigate(any<NavDirections>())
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/DefaultQuickSettingsControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/DefaultQuickSettingsControllerTest.kt
new file mode 100644
index 0000000000..94ce398b2d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/DefaultQuickSettingsControllerTest.kt
@@ -0,0 +1,444 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.quicksettings
+
+import androidx.navigation.NavController
+import androidx.navigation.NavDirections
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.coVerifyOrder
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import kotlinx.coroutines.test.advanceUntilIdle
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.Engine
+import mozilla.components.concept.engine.permission.SitePermissions
+import mozilla.components.concept.engine.permission.SitePermissions.Status.NO_DECISION
+import mozilla.components.feature.session.SessionUseCases
+import mozilla.components.feature.session.TrackingProtectionUseCases
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.CookieBanners
+import org.mozilla.fenix.GleanMetrics.TrackingProtection
+import org.mozilla.fenix.components.PermissionStorage
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.directionsEq
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.PhoneFeature
+import org.mozilla.fenix.settings.quicksettings.ext.shouldBeEnabled
+import org.mozilla.fenix.settings.toggle
+import org.mozilla.fenix.trackingprotection.CookieBannerUIMode
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class DefaultQuickSettingsControllerTest {
+ private val context = spyk(testContext)
+
+ private lateinit var browserStore: BrowserStore
+ private lateinit var tab: TabSessionState
+
+ @MockK
+ private lateinit var store: QuickSettingsFragmentStore
+
+ @MockK(relaxed = true)
+ private lateinit var navController: NavController
+
+ @MockK(relaxed = true)
+ private lateinit var sitePermissions: SitePermissions
+
+ @MockK(relaxed = true)
+ private lateinit var appSettings: Settings
+
+ @MockK(relaxed = true)
+ private lateinit var permissionStorage: PermissionStorage
+
+ @MockK(relaxed = true)
+ private lateinit var engine: Engine
+
+ @MockK(relaxed = true)
+ private lateinit var reload: SessionUseCases.ReloadUrlUseCase
+
+ @MockK(relaxed = true)
+ private lateinit var requestPermissions: (Array<String>) -> Unit
+
+ private lateinit var controller: DefaultQuickSettingsController
+
+ @get:Rule
+ val gleanRule = GleanTestRule(testContext)
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val scope = coroutinesTestRule.scope
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+
+ tab = createTab("https://mozilla.org")
+ browserStore = BrowserStore(BrowserState(tabs = listOf(tab)))
+ sitePermissions = SitePermissions(origin = "", savedAt = 123)
+
+ controller = spyk(
+ DefaultQuickSettingsController(
+ context = context,
+ quickSettingsStore = store,
+ browserStore = browserStore,
+ sessionId = tab.id,
+ ioScope = scope,
+ navController = navController,
+ sitePermissions = sitePermissions,
+ settings = appSettings,
+ permissionStorage = permissionStorage,
+ reload = reload,
+ requestRuntimePermissions = requestPermissions,
+ engine = engine,
+ displayPermissions = {},
+ ),
+ )
+ }
+
+ @Test
+ fun `handlePermissionsShown should delegate to an injected parameter`() = runTestOnMain {
+ every { testContext.components.core.engine } returns mockk(relaxed = true)
+ var displayPermissionsInvoked = false
+ createController(
+ displayPermissions = {
+ displayPermissionsInvoked = true
+ },
+ ).handlePermissionsShown()
+
+ assertTrue(displayPermissionsInvoked)
+ }
+
+ @Test
+ fun `handlePermissionToggled blocked by Android should handleAndroidPermissionRequest`() = runTestOnMain {
+ val cameraFeature = PhoneFeature.CAMERA
+ val websitePermission = mockk<WebsitePermission>()
+ every { websitePermission.phoneFeature } returns cameraFeature
+ every { websitePermission.isBlockedByAndroid } returns true
+
+ controller.handlePermissionToggled(websitePermission)
+
+ verify {
+ controller.handleAndroidPermissionRequest(cameraFeature.androidPermissionsList)
+ }
+ }
+
+ @Test
+ fun `handlePermissionToggled allowed by Android should toggle the permissions and modify View's state`() = runTestOnMain {
+ val websitePermission = mockk<WebsitePermission>()
+ every { websitePermission.phoneFeature } returns PhoneFeature.CAMERA
+ every { websitePermission.isBlockedByAndroid } returns false
+ every { store.dispatch(any()) } returns mockk()
+
+ controller.handlePermissionToggled(websitePermission)
+
+ // We want to verify that the Status is toggled and this event is passed to Controller also.
+ assertSame(NO_DECISION, sitePermissions.camera)
+ verify {
+ controller.handlePermissionsChange(sitePermissions.toggle(PhoneFeature.CAMERA))
+ }
+ // We should also modify View's state. Not necessarily as the last operation.
+ verify {
+ store.dispatch(
+ match { action ->
+ PhoneFeature.CAMERA == (action as WebsitePermissionAction.TogglePermission).updatedFeature
+ },
+ )
+ }
+ }
+
+ @Test
+ fun `handlePermissionToggled blocked by user should navigate to site permission manager`() = runTestOnMain {
+ every { testContext.components.core.engine } returns mockk(relaxed = true)
+ val websitePermission = mockk<WebsitePermission>()
+ val invalidSitePermissionsController = DefaultQuickSettingsController(
+ context = context,
+ quickSettingsStore = store,
+ browserStore = BrowserStore(),
+ ioScope = scope,
+ navController = navController,
+ sessionId = "123",
+ sitePermissions = null,
+ settings = appSettings,
+ permissionStorage = permissionStorage,
+ reload = reload,
+ requestRuntimePermissions = requestPermissions,
+ displayPermissions = {},
+ )
+
+ every { websitePermission.phoneFeature } returns PhoneFeature.CAMERA
+ every { websitePermission.isBlockedByAndroid } returns false
+ every { navController.navigate(any<NavDirections>()) } just Runs
+
+ invalidSitePermissionsController.handlePermissionToggled(websitePermission)
+
+ verify {
+ navController.navigate(
+ directionsEq(
+ QuickSettingsSheetDialogFragmentDirections.actionGlobalSitePermissionsManagePhoneFeature(
+ PhoneFeature.CAMERA,
+ ),
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `handleAutoplayChanged will add autoplay permission`() = runTestOnMain {
+ val autoplayValue = mockk<AutoplayValue.AllowAll>(relaxed = true)
+
+ every { store.dispatch(any()) } returns mockk()
+ every { controller.handleAutoplayAdd(any(), any()) } returns Unit
+
+ controller.sitePermissions = null
+
+ controller.handleAutoplayChanged(autoplayValue)
+
+ verify {
+ controller.handleAutoplayAdd(any(), any())
+ store.dispatch(any())
+ }
+ }
+
+ @Test
+ fun `handleAutoplayChanged will update autoplay permission`() = runTestOnMain {
+ val autoplayValue = mockk<AutoplayValue.AllowAll>(relaxed = true)
+
+ every { store.dispatch(any()) } returns mockk()
+ every { controller.handleAutoplayAdd(any(), any()) } returns Unit
+ every { controller.handlePermissionsChange(any()) } returns Unit
+ every { autoplayValue.updateSitePermissions(any()) } returns mockk()
+
+ controller.handleAutoplayChanged(autoplayValue)
+
+ verify {
+ autoplayValue.updateSitePermissions(any())
+ store.dispatch(any())
+ }
+ }
+
+ @Test
+ fun `handleAndroidPermissionGranted should update the View's state`() = runTestOnMain {
+ val featureGranted = PhoneFeature.CAMERA
+ val permissionStatus = featureGranted.getActionLabel(context, sitePermissions, appSettings)
+ val permissionEnabled =
+ featureGranted.shouldBeEnabled(context, sitePermissions, appSettings)
+ every { store.dispatch(any()) } returns mockk()
+
+ controller.handleAndroidPermissionGranted(featureGranted)
+
+ verify {
+ store.dispatch(
+ withArg { action ->
+ action as WebsitePermissionAction.TogglePermission
+ assertEquals(featureGranted, action.updatedFeature)
+ assertEquals(permissionStatus, action.updatedStatus)
+ assertEquals(permissionEnabled, action.updatedEnabledStatus)
+ },
+ )
+ }
+ }
+
+ @Test
+ fun `handleAndroidPermissionRequest should request from the injected callback`() = runTestOnMain {
+ every { testContext.components.core.engine } returns mockk(relaxed = true)
+ val testPermissions = arrayOf("TestPermission")
+
+ var requestRuntimePermissionsInvoked = false
+ createController(
+ requestPermissions = {
+ assertArrayEquals(testPermissions, it)
+ requestRuntimePermissionsInvoked = true
+ },
+ ).handleAndroidPermissionRequest(testPermissions)
+
+ assertTrue(requestRuntimePermissionsInvoked)
+ }
+
+ @Test
+ fun `handlePermissionsChange should store the updated permission and reload webpage`() =
+ runTestOnMain {
+ val testPermissions = mockk<SitePermissions>()
+
+ controller.handlePermissionsChange(testPermissions)
+ advanceUntilIdle()
+
+ coVerifyOrder {
+ permissionStorage.updateSitePermissions(testPermissions, tab.content.private)
+ reload(tab.id)
+ }
+ }
+
+ @Test
+ fun `handleAutoplayAdd should store the updated permission and reload webpage`() =
+ runTestOnMain {
+ val testPermissions = mockk<SitePermissions>()
+
+ controller.handleAutoplayAdd(testPermissions, true)
+ advanceUntilIdle()
+
+ coVerifyOrder {
+ permissionStorage.add(testPermissions, true)
+ reload(tab.id)
+ }
+ }
+
+ @Test
+ fun `handleCookieBannerHandlingDetailsClicked should call popBackStack and navigate to details page`() {
+ every { context.components.core.store } returns browserStore
+ every { store.state.protectionsState } returns mockk(relaxed = true)
+ every { context.components.settings } returns appSettings
+ every { context.components.settings.toolbarPosition.androidGravity } returns mockk(relaxed = true)
+
+ controller.handleCookieBannerHandlingDetailsClicked()
+
+ verify {
+ navController.popBackStack()
+
+ navController.navigate(any<NavDirections>())
+ }
+ assertNotNull(CookieBanners.visitedPanel.testGetValue())
+ }
+
+ @Test
+ fun `handleTrackingProtectionToggled should call the right use cases`() = runTestOnMain {
+ val trackingProtectionUseCases: TrackingProtectionUseCases = mockk(relaxed = true)
+ val sessionUseCases: SessionUseCases = mockk(relaxed = true)
+
+ every { context.components.core.store } returns browserStore
+ every { context.components.useCases.trackingProtectionUseCases } returns trackingProtectionUseCases
+ every { context.components.useCases.sessionUseCases } returns sessionUseCases
+ every { store.dispatch(any()) } returns mockk()
+
+ var isEnabled = true
+
+ controller.handleTrackingProtectionToggled(isEnabled)
+
+ verify {
+ trackingProtectionUseCases.removeException(tab.id)
+ sessionUseCases.reload.invoke(tab.id)
+ store.dispatch(TrackingProtectionAction.ToggleTrackingProtectionEnabled(isEnabled))
+ }
+
+ isEnabled = false
+ assertNull(TrackingProtection.exceptionAdded.testGetValue())
+
+ controller.handleTrackingProtectionToggled(isEnabled)
+
+ assertNotNull(TrackingProtection.exceptionAdded.testGetValue())
+ verify {
+ trackingProtectionUseCases.addException(tab.id)
+ sessionUseCases.reload.invoke(tab.id)
+ store.dispatch(TrackingProtectionAction.ToggleTrackingProtectionEnabled(isEnabled))
+ }
+ }
+
+ @Test
+ fun `handleBlockedItemsClicked should call popBackStack and navigate to the tracking protection panel dialog`() = runTestOnMain {
+ every { context.components.core.store } returns browserStore
+ every { context.components.settings } returns appSettings
+ every { context.components.settings.toolbarPosition.androidGravity } returns mockk(relaxed = true)
+
+ val isTrackingProtectionEnabled = true
+ val state = QuickSettingsFragmentStore.createTrackingProtectionState(
+ context = context,
+ websiteUrl = tab.content.url,
+ sessionId = tab.id,
+ isTrackingProtectionEnabled = isTrackingProtectionEnabled,
+ cookieBannerUIMode = CookieBannerUIMode.ENABLE,
+ )
+
+ every { store.state.protectionsState } returns state
+
+ controller.handleTrackingProtectionDetailsClicked()
+
+ verify {
+ navController.popBackStack()
+
+ navController.navigate(any<NavDirections>())
+ }
+ }
+
+ @Test
+ fun `WHEN handleConnectionDetailsClicked THEN call popBackStack and navigate to the connection details dialog`() = runTestOnMain {
+ every { context.components.core.store } returns browserStore
+ every { context.components.settings } returns appSettings
+ every { context.components.settings.toolbarPosition.androidGravity } returns mockk(relaxed = true)
+
+ val state = WebsiteInfoState.createWebsiteInfoState(
+ websiteUrl = tab.content.url,
+ websiteTitle = tab.content.title,
+ isSecured = true,
+ certificateName = "certificateName",
+ )
+
+ every { store.state.webInfoState } returns state
+
+ controller.handleConnectionDetailsClicked()
+
+ verify {
+ navController.popBackStack()
+
+ navController.navigate(any<NavDirections>())
+ }
+ }
+
+ @Test
+ fun `WHEN handleClearSiteData THEN call clearSite`() = runTestOnMain {
+ controller.handleClearSiteDataClicked("mozilla.org")
+
+ verify {
+ engine.clearData(
+ host = "mozilla.org",
+ data = Engine.BrowsingData.select(
+ Engine.BrowsingData.AUTH_SESSIONS,
+ Engine.BrowsingData.ALL_SITE_DATA,
+ ),
+ )
+ }
+ }
+
+ private fun createController(
+ requestPermissions: (Array<String>) -> Unit = { _ -> },
+ displayPermissions: () -> Unit = {},
+ ): DefaultQuickSettingsController {
+ return spyk(
+ DefaultQuickSettingsController(
+ context = context,
+ quickSettingsStore = store,
+ browserStore = browserStore,
+ sessionId = tab.id,
+ ioScope = scope,
+ navController = navController,
+ sitePermissions = sitePermissions,
+ settings = appSettings,
+ permissionStorage = permissionStorage,
+ reload = reload,
+ requestRuntimePermissions = requestPermissions,
+ displayPermissions = displayPermissions,
+ ),
+ )
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/ProtectionsViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/ProtectionsViewTest.kt
new file mode 100644
index 0000000000..c88152ebcd
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/ProtectionsViewTest.kt
@@ -0,0 +1,193 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.quicksettings
+
+import android.view.View
+import android.widget.FrameLayout
+import androidx.core.view.isVisible
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.spyk
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.databinding.QuicksettingsProtectionsPanelBinding
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.quicksettings.protections.ProtectionsInteractor
+import org.mozilla.fenix.settings.quicksettings.protections.ProtectionsView
+import org.mozilla.fenix.trackingprotection.CookieBannerUIMode
+import org.mozilla.fenix.trackingprotection.ProtectionsState
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class ProtectionsViewTest {
+
+ private lateinit var view: ProtectionsView
+ private lateinit var binding: QuicksettingsProtectionsPanelBinding
+ private lateinit var interactor: ProtectionsInteractor
+ private var trackingProtectionDivider: View = spyk(View(testContext))
+
+ @MockK(relaxed = true)
+ private lateinit var settings: Settings
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ interactor = mockk(relaxed = true)
+ view = spyk(
+ ProtectionsView(
+ FrameLayout(testContext),
+ trackingProtectionDivider,
+ interactor,
+ settings,
+ ),
+ )
+ binding = view.binding
+ }
+
+ @Test
+ fun `WHEN updating THEN bind checkbox`() {
+ val websiteUrl = "https://mozilla.org"
+ val state = ProtectionsState(
+ tab = createTab(url = websiteUrl),
+ url = websiteUrl,
+ isTrackingProtectionEnabled = true,
+ cookieBannerUIMode = CookieBannerUIMode.ENABLE,
+ listTrackers = listOf(),
+ mode = ProtectionsState.Mode.Normal,
+ lastAccessedCategory = "",
+ )
+
+ every { settings.shouldUseTrackingProtection } returns true
+
+ view.update(state)
+
+ assertTrue(binding.root.isVisible)
+ assertTrue(binding.trackingProtectionSwitch.isChecked)
+ }
+
+ @Test
+ fun `GIVEN TP is globally off WHEN updating THEN hide the TP section`() {
+ val websiteUrl = "https://mozilla.org"
+ val state = ProtectionsState(
+ tab = createTab(url = websiteUrl),
+ url = websiteUrl,
+ isTrackingProtectionEnabled = true,
+ cookieBannerUIMode = CookieBannerUIMode.ENABLE,
+ listTrackers = listOf(),
+ mode = ProtectionsState.Mode.Normal,
+ lastAccessedCategory = "",
+ )
+
+ every { settings.shouldUseTrackingProtection } returns false
+
+ view.update(state)
+
+ assertFalse(binding.trackingProtectionSwitch.isVisible)
+ }
+
+ @Test
+ fun `GIVEN cookie banners handling is globally off WHEN updating THEN hide the cookie banner section`() {
+ val websiteUrl = "https://mozilla.org"
+ val state = ProtectionsState(
+ tab = createTab(url = websiteUrl),
+ url = websiteUrl,
+ isTrackingProtectionEnabled = true,
+ cookieBannerUIMode = CookieBannerUIMode.ENABLE,
+ listTrackers = listOf(),
+ mode = ProtectionsState.Mode.Normal,
+ lastAccessedCategory = "",
+ )
+
+ every { settings.shouldShowCookieBannerUI } returns true
+ every { settings.shouldUseCookieBannerPrivateMode } returns false
+
+ view.update(state)
+
+ assertFalse(binding.cookieBannerItem.isVisible)
+ }
+
+ @Test
+ fun `GIVEN cookie banners handling UI feature flag is off WHEN updating THEN hide the cookie banner section`() {
+ val websiteUrl = "https://mozilla.org"
+ val state = ProtectionsState(
+ tab = createTab(url = websiteUrl),
+ url = websiteUrl,
+ isTrackingProtectionEnabled = true,
+ cookieBannerUIMode = CookieBannerUIMode.ENABLE,
+ listTrackers = listOf(),
+ mode = ProtectionsState.Mode.Normal,
+ lastAccessedCategory = "",
+ )
+
+ every { settings.shouldShowCookieBannerUI } returns false
+ every { settings.shouldUseCookieBannerPrivateMode } returns false
+
+ view.update(state)
+
+ assertFalse(binding.cookieBannerItem.isVisible)
+ }
+
+ @Test
+ fun `GIVEN cookie banners handling mode is hide WHEN updating THEN hide the cookie banner section`() {
+ val websiteUrl = "https://mozilla.org"
+ val state = ProtectionsState(
+ tab = createTab(url = websiteUrl),
+ url = websiteUrl,
+ isTrackingProtectionEnabled = true,
+ cookieBannerUIMode = CookieBannerUIMode.HIDE,
+ listTrackers = listOf(),
+ mode = ProtectionsState.Mode.Normal,
+ lastAccessedCategory = "",
+ )
+
+ every { settings.shouldShowCookieBannerUI } returns true
+ every { settings.shouldUseCookieBannerPrivateMode } returns true
+
+ view.update(state)
+
+ assertFalse(binding.cookieBannerItem.isVisible)
+ }
+
+ @Test
+ fun `WHEN updateDetailsSection is called THEN update the visibility of the section`() {
+ every { settings.shouldUseTrackingProtection } returns false
+
+ view.updateDetailsSection(false)
+
+ assertFalse(binding.trackingProtectionDetails.isVisible)
+
+ view.updateDetailsSection(true)
+
+ assertTrue(binding.trackingProtectionDetails.isVisible)
+ }
+
+ @Test
+ fun `WHEN all the views from protectionView are gone THEN tracking protection divider is gone`() {
+ val websiteUrl = "https://mozilla.org"
+ val state = ProtectionsState(
+ tab = createTab(url = websiteUrl),
+ url = websiteUrl,
+ isTrackingProtectionEnabled = false,
+ cookieBannerUIMode = CookieBannerUIMode.HIDE,
+ listTrackers = listOf(),
+ mode = ProtectionsState.Mode.Normal,
+ lastAccessedCategory = "",
+ )
+
+ every { settings.shouldShowCookieBannerUI } returns true
+ every { settings.shouldUseCookieBannerPrivateMode } returns true
+
+ view.update(state)
+
+ assertFalse(trackingProtectionDivider.isVisible)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsFragmentReducerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsFragmentReducerTest.kt
new file mode 100644
index 0000000000..e34e9af6a4
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsFragmentReducerTest.kt
@@ -0,0 +1,133 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.quicksettings
+
+import kotlinx.coroutines.test.runTest
+import mozilla.components.feature.sitepermissions.SitePermissionsRules
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotSame
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.mozilla.fenix.settings.PhoneFeature
+import org.mozilla.fenix.trackingprotection.CookieBannerUIMode
+import org.mozilla.fenix.trackingprotection.ProtectionsState
+import org.mozilla.fenix.trackingprotection.ProtectionsState.Mode.Normal
+
+class QuickSettingsFragmentReducerTest {
+
+ @Test
+ fun `WebsitePermissionAction - TogglePermission`() {
+ val toggleablePermission = WebsitePermission.Toggleable(
+ phoneFeature = PhoneFeature.CAMERA,
+ status = "status",
+ isVisible = false,
+ isEnabled = false,
+ isBlockedByAndroid = false,
+ )
+
+ val map =
+ mapOf<PhoneFeature, WebsitePermission>(PhoneFeature.CAMERA to toggleablePermission)
+ val infoState = WebsiteInfoState("", "", WebsiteSecurityUiValues.SECURE, "")
+ val tpState = ProtectionsState(
+ null,
+ "",
+ isTrackingProtectionEnabled = false,
+ cookieBannerUIMode = CookieBannerUIMode.DISABLE,
+ listTrackers = emptyList(),
+ mode = Normal,
+ lastAccessedCategory = "",
+ )
+ val state = QuickSettingsFragmentState(infoState, map, tpState)
+ val newState = quickSettingsFragmentReducer(
+ state,
+ WebsitePermissionAction.TogglePermission(
+ updatedFeature = PhoneFeature.CAMERA,
+ updatedStatus = "newStatus",
+ updatedEnabledStatus = true,
+ ),
+ )
+ val result = newState.websitePermissionsState[PhoneFeature.CAMERA]!!
+ assertEquals("newStatus", result.status)
+ assertTrue(result.isEnabled)
+ }
+
+ @Test
+ fun `WebsitePermissionAction - ChangeAutoplay`() {
+ val permissionPermission = WebsitePermission.Autoplay(
+ autoplayValue = AutoplayValue.BlockAll(
+ label = "label",
+ rules = createTestRule(),
+ sitePermission = null,
+ ),
+ options = emptyList(),
+ isVisible = false,
+ )
+
+ val map =
+ mapOf<PhoneFeature, WebsitePermission>(PhoneFeature.AUTOPLAY to permissionPermission)
+ val infoState = WebsiteInfoState("", "", WebsiteSecurityUiValues.SECURE, "")
+ val tpState = ProtectionsState(
+ null,
+ "",
+ isTrackingProtectionEnabled = false,
+ cookieBannerUIMode = CookieBannerUIMode.DISABLE,
+ listTrackers = emptyList(),
+ mode = Normal,
+ lastAccessedCategory = "",
+ )
+ val state = QuickSettingsFragmentState(infoState, map, tpState)
+ val autoplayValue = AutoplayValue.AllowAll(
+ label = "newLabel",
+ rules = createTestRule(),
+ sitePermission = null,
+ )
+ val newState = quickSettingsFragmentReducer(
+ state,
+ WebsitePermissionAction.ChangeAutoplay(autoplayValue),
+ )
+
+ val result =
+ newState.websitePermissionsState[PhoneFeature.AUTOPLAY] as WebsitePermission.Autoplay
+ assertEquals(autoplayValue, result.autoplayValue)
+ }
+
+ @Test
+ fun `ProtectionsAction - ToggleTrackingProtectionEnabled`() = runTest {
+ val state = QuickSettingsFragmentState(
+ webInfoState = WebsiteInfoState("", "", WebsiteSecurityUiValues.SECURE, ""),
+ websitePermissionsState = emptyMap(),
+ protectionsState = ProtectionsState(
+ tab = null,
+ url = "https://www.firefox.com",
+ isTrackingProtectionEnabled = true,
+ cookieBannerUIMode = CookieBannerUIMode.ENABLE,
+ listTrackers = listOf(),
+ mode = Normal,
+ lastAccessedCategory = "",
+ ),
+ )
+
+ val newState = quickSettingsFragmentReducer(
+ state = state,
+ action = TrackingProtectionAction.ToggleTrackingProtectionEnabled(false),
+ )
+
+ assertNotSame(state, newState)
+ assertFalse(newState.protectionsState.isTrackingProtectionEnabled)
+ }
+
+ private fun createTestRule() = SitePermissionsRules(
+ SitePermissionsRules.Action.ALLOWED,
+ SitePermissionsRules.Action.ALLOWED,
+ SitePermissionsRules.Action.ALLOWED,
+ SitePermissionsRules.Action.ALLOWED,
+ SitePermissionsRules.AutoplayAction.ALLOWED,
+ SitePermissionsRules.AutoplayAction.ALLOWED,
+ SitePermissionsRules.Action.ALLOWED,
+ SitePermissionsRules.Action.ALLOWED,
+ SitePermissionsRules.Action.ALLOWED,
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsFragmentStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsFragmentStoreTest.kt
new file mode 100644
index 0000000000..fa9be1e19f
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsFragmentStoreTest.kt
@@ -0,0 +1,378 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.quicksettings
+
+import android.content.pm.PackageManager
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.content.PermissionHighlightsState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.permission.SitePermissions
+import mozilla.components.feature.sitepermissions.SitePermissionsRules
+import mozilla.components.feature.sitepermissions.SitePermissionsRules.Action
+import mozilla.components.feature.sitepermissions.SitePermissionsRules.AutoplayAction
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNotSame
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.PhoneFeature
+import org.mozilla.fenix.settings.quicksettings.QuickSettingsFragmentStore.Companion.toWebsitePermission
+import org.mozilla.fenix.settings.quicksettings.WebsiteInfoState.Companion.createWebsiteInfoState
+import org.mozilla.fenix.settings.quicksettings.ext.shouldBeEnabled
+import org.mozilla.fenix.settings.quicksettings.ext.shouldBeVisible
+import org.mozilla.fenix.settings.sitepermissions.AUTOPLAY_BLOCK_ALL
+import org.mozilla.fenix.trackingprotection.CookieBannerUIMode
+import org.mozilla.fenix.trackingprotection.ProtectionsState
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class QuickSettingsFragmentStoreTest {
+ private val context = spyk(testContext)
+
+ @MockK(relaxed = true)
+ private lateinit var permissions: SitePermissions
+
+ @MockK(relaxed = true)
+ private lateinit var permissionHighlights: PermissionHighlightsState
+
+ @MockK(relaxed = true)
+ private lateinit var appSettings: Settings
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+
+ every { appSettings.getSitePermissionsCustomSettingsRules() } returns getRules()
+ }
+
+ @Test
+ fun `createStore constructs a QuickSettingsFragmentState`() {
+ val tab = createTab(
+ url = "https://www.firefox.com",
+ title = "Firefox",
+ )
+ val browserStore = BrowserStore(BrowserState(tabs = listOf(tab)))
+
+ every { context.components.core.store } returns browserStore
+
+ val store = QuickSettingsFragmentStore.createStore(
+ context = context,
+ websiteUrl = tab.content.url,
+ websiteTitle = tab.content.title,
+ certificateName = "issuer",
+ isSecured = true,
+ permissions = permissions,
+ permissionHighlights = permissionHighlights,
+ settings = appSettings,
+ sessionId = tab.id,
+ isTrackingProtectionEnabled = true,
+ cookieBannerUIMode = CookieBannerUIMode.ENABLE,
+ )
+
+ assertNotNull(store)
+ assertNotNull(store.state)
+ assertNotNull(store.state.webInfoState)
+ assertNotNull(store.state.websitePermissionsState)
+ assertNotNull(store.state.protectionsState)
+ }
+
+ @Test
+ fun `createWebsiteInfoState constructs a WebsiteInfoState with the right values for a secure connection`() {
+ val websiteUrl = "https://host.com/page1"
+ val websiteTitle = "Hello"
+ val certificateIssuer = "issuer"
+ val securedStatus = true
+
+ val state = createWebsiteInfoState(websiteUrl, websiteTitle, securedStatus, certificateIssuer)
+
+ assertNotNull(state)
+ assertSame(websiteUrl, state.websiteUrl)
+ assertSame(websiteTitle, state.websiteTitle)
+ assertEquals(WebsiteSecurityUiValues.SECURE, state.websiteSecurityUiValues)
+ }
+
+ @Test
+ fun `createWebsiteInfoState constructs a WebsiteInfoState with the right values for an insecure connection`() {
+ val websiteUrl = "https://host.com/page1"
+ val websiteTitle = "Hello"
+ val certificateIssuer = "issuer"
+ val securedStatus = false
+
+ val state = createWebsiteInfoState(websiteUrl, websiteTitle, securedStatus, certificateIssuer)
+
+ assertNotNull(state)
+ assertSame(websiteUrl, state.websiteUrl)
+ assertSame(websiteTitle, state.websiteTitle)
+ assertEquals(WebsiteSecurityUiValues.INSECURE, state.websiteSecurityUiValues)
+ }
+
+ @Test
+ fun `createWebsitePermissionState helps in constructing an initial WebsitePermissionState for it's Store`() {
+ val permissionHighlights = mockk<PermissionHighlightsState>(relaxed = true)
+
+ every {
+ context.checkPermission(
+ any(),
+ any(),
+ any(),
+ )
+ }.returns(PackageManager.PERMISSION_GRANTED)
+ every { permissions.camera } returns SitePermissions.Status.ALLOWED
+ every { permissions.microphone } returns SitePermissions.Status.NO_DECISION
+ every { permissions.notification } returns SitePermissions.Status.BLOCKED
+ every { permissions.location } returns SitePermissions.Status.ALLOWED
+ every { permissions.localStorage } returns SitePermissions.Status.ALLOWED
+ every { permissions.crossOriginStorageAccess } returns SitePermissions.Status.ALLOWED
+ every { permissions.mediaKeySystemAccess } returns SitePermissions.Status.NO_DECISION
+ every { permissions.autoplayAudible } returns SitePermissions.AutoplayStatus.ALLOWED
+ every { permissions.autoplayInaudible } returns SitePermissions.AutoplayStatus.BLOCKED
+ every { appSettings.getAutoplayUserSetting() } returns AUTOPLAY_BLOCK_ALL
+
+ val state = QuickSettingsFragmentStore.createWebsitePermissionState(
+ context,
+ permissions,
+ permissionHighlights,
+ appSettings,
+ )
+
+ // Just need to know that the WebsitePermissionsState properties are initialized.
+ // Making sure they are correctly initialized is tested in the `initWebsitePermission` test.
+ assertNotNull(state)
+ assertNotNull(state[PhoneFeature.CAMERA])
+ assertNotNull(state[PhoneFeature.MICROPHONE])
+ assertNotNull(state[PhoneFeature.NOTIFICATION])
+ assertNotNull(state[PhoneFeature.LOCATION])
+ assertNotNull(state[PhoneFeature.AUTOPLAY_AUDIBLE])
+ assertNotNull(state[PhoneFeature.AUTOPLAY_INAUDIBLE])
+ assertNotNull(state[PhoneFeature.PERSISTENT_STORAGE])
+ assertNotNull(state[PhoneFeature.CROSS_ORIGIN_STORAGE_ACCESS])
+ assertNotNull(state[PhoneFeature.MEDIA_KEY_SYSTEM_ACCESS])
+ assertNotNull(state[PhoneFeature.AUTOPLAY])
+ }
+
+ @Test
+ fun `PhoneFeature#toWebsitePermission helps in constructing the right WebsitePermission`() {
+ val cameraFeature = PhoneFeature.CAMERA
+ val allowedStatus = testContext.getString(R.string.preference_option_phone_feature_allowed)
+ every {
+ context.checkPermission(
+ any(),
+ any(),
+ any(),
+ )
+ }.returns(PackageManager.PERMISSION_GRANTED)
+ every { permissions.camera } returns SitePermissions.Status.ALLOWED
+ every { permissionHighlights.isAutoPlayBlocking } returns true
+
+ val websitePermission = cameraFeature.toWebsitePermission(
+ context = context,
+ permissions = permissions,
+ permissionHighlights = permissionHighlights,
+ settings = appSettings,
+ )
+
+ assertNotNull(websitePermission)
+ assertEquals(cameraFeature, websitePermission.phoneFeature)
+ assertEquals(allowedStatus, websitePermission.status)
+ assertTrue(websitePermission.isVisible)
+ assertTrue(websitePermission.isEnabled)
+ assertFalse(websitePermission.isBlockedByAndroid)
+
+ val autoplayPermission = PhoneFeature.AUTOPLAY.toWebsitePermission(
+ context = context,
+ permissions = permissions,
+ permissionHighlights = permissionHighlights,
+ settings = appSettings,
+ ) as WebsitePermission.Autoplay
+
+ assertNotNull(autoplayPermission)
+ assertNotNull(autoplayPermission.autoplayValue)
+ assertEquals(PhoneFeature.AUTOPLAY, autoplayPermission.phoneFeature)
+ assertTrue(websitePermission.isVisible)
+ assertTrue(websitePermission.isEnabled)
+ }
+
+ @Test
+ fun `PhoneFeature#getPermissionStatus gets the permission properties from delegates`() {
+ val permissionHighlights = mockk<PermissionHighlightsState>(relaxed = true)
+ val phoneFeature = PhoneFeature.CAMERA
+ every { permissions.camera } returns SitePermissions.Status.NO_DECISION
+
+ val permissionsStatus = phoneFeature.toWebsitePermission(
+ context,
+ permissions,
+ permissionHighlights,
+ appSettings,
+ )
+
+ verify {
+ // Verifying phoneFeature.getActionLabel gets "Status(child of #2#4).ordinal()) was not called"
+// phoneFeature.getActionLabel(context, permissions, appSettings)
+ phoneFeature.shouldBeVisible(permissions, appSettings)
+ phoneFeature.shouldBeEnabled(context, permissions, appSettings)
+ phoneFeature.isAndroidPermissionGranted(context)
+ }
+
+ // Check that we only have a non-null permission status.
+ // Having each property calculated in a separate delegate means their correctness is
+ // to be tested in that delegated method.
+ assertNotNull(permissionsStatus)
+ }
+
+ @Test
+ fun `TogglePermission should only modify status and visibility of a specific WebsitePermissionsState`() =
+ runTest {
+ val initialCameraStatus = "initialCameraStatus"
+ val initialMicStatus = "initialMicStatus"
+ val initialNotificationStatus = "initialNotificationStatus"
+ val initialLocationStatus = "initialLocationStatus"
+ val initialAutoplayAudibleStatus = "initialAutoplayAudibleStatus"
+ val initialAutoplayInaudibleStatus = "initialAutoplayInaudibleStatus"
+ val updatedMicrophoneStatus = "updatedNotificationStatus"
+ val updatedMicrophoneEnabledStatus = false
+ val defaultVisibilityStatus = true
+ val defaultEnabledStatus = true
+ val defaultBlockedByAndroidStatus = true
+ val websiteInfoState = mockk<WebsiteInfoState>()
+ val baseWebsitePermission = WebsitePermission.Toggleable(
+ phoneFeature = PhoneFeature.CAMERA,
+ status = "",
+ isVisible = true,
+ isEnabled = true,
+ isBlockedByAndroid = true,
+ )
+ val initialWebsitePermissionsState = mapOf(
+ PhoneFeature.CAMERA to baseWebsitePermission.copy(
+ phoneFeature = PhoneFeature.CAMERA,
+ status = initialCameraStatus,
+ ),
+ PhoneFeature.MICROPHONE to baseWebsitePermission.copy(
+ phoneFeature = PhoneFeature.MICROPHONE,
+ status = initialMicStatus,
+ ),
+ PhoneFeature.NOTIFICATION to baseWebsitePermission.copy(
+ phoneFeature = PhoneFeature.NOTIFICATION,
+ status = initialNotificationStatus,
+ ),
+ PhoneFeature.LOCATION to baseWebsitePermission.copy(
+ phoneFeature = PhoneFeature.LOCATION,
+ status = initialLocationStatus,
+ ),
+ PhoneFeature.AUTOPLAY_AUDIBLE to baseWebsitePermission.copy(
+ phoneFeature = PhoneFeature.AUTOPLAY_AUDIBLE,
+ status = initialAutoplayAudibleStatus,
+ ),
+ PhoneFeature.AUTOPLAY_INAUDIBLE to baseWebsitePermission.copy(
+ phoneFeature = PhoneFeature.AUTOPLAY_INAUDIBLE,
+ status = initialAutoplayInaudibleStatus,
+ ),
+ )
+ val initialState = QuickSettingsFragmentState(
+ webInfoState = websiteInfoState,
+ websitePermissionsState = initialWebsitePermissionsState,
+ protectionsState = mockk(),
+ )
+ val store = QuickSettingsFragmentStore(initialState)
+
+ store.dispatch(
+ WebsitePermissionAction.TogglePermission(
+ PhoneFeature.MICROPHONE,
+ updatedMicrophoneStatus,
+ updatedMicrophoneEnabledStatus,
+ ),
+ ).join()
+
+ assertNotNull(store.state)
+ assertNotSame(initialState, store.state)
+ assertNotSame(initialWebsitePermissionsState, store.state.websitePermissionsState)
+ assertSame(websiteInfoState, store.state.webInfoState)
+
+ assertNotNull(store.state.websitePermissionsState[PhoneFeature.CAMERA])
+ assertEquals(PhoneFeature.CAMERA, store.state.websitePermissionsState.getValue(PhoneFeature.CAMERA).phoneFeature)
+ assertEquals(initialCameraStatus, store.state.websitePermissionsState.getValue(PhoneFeature.CAMERA).status)
+ assertEquals(defaultVisibilityStatus, store.state.websitePermissionsState.getValue(PhoneFeature.CAMERA).isVisible)
+ assertEquals(defaultEnabledStatus, store.state.websitePermissionsState.getValue(PhoneFeature.CAMERA).isEnabled)
+ assertEquals(defaultBlockedByAndroidStatus, store.state.websitePermissionsState.getValue(PhoneFeature.CAMERA).isBlockedByAndroid)
+
+ assertNotNull(store.state.websitePermissionsState[PhoneFeature.MICROPHONE])
+ assertEquals(PhoneFeature.MICROPHONE, store.state.websitePermissionsState.getValue(PhoneFeature.MICROPHONE).phoneFeature)
+
+ // Only the following two properties must have been changed!
+ assertEquals(updatedMicrophoneStatus, store.state.websitePermissionsState.getValue(PhoneFeature.MICROPHONE).status)
+ assertEquals(updatedMicrophoneEnabledStatus, store.state.websitePermissionsState.getValue(PhoneFeature.MICROPHONE).isEnabled)
+
+ assertEquals(defaultVisibilityStatus, store.state.websitePermissionsState.getValue(PhoneFeature.MICROPHONE).isVisible)
+ assertEquals(defaultBlockedByAndroidStatus, store.state.websitePermissionsState.getValue(PhoneFeature.MICROPHONE).isBlockedByAndroid)
+
+ assertNotNull(store.state.websitePermissionsState[PhoneFeature.NOTIFICATION])
+ assertEquals(PhoneFeature.NOTIFICATION, store.state.websitePermissionsState.getValue(PhoneFeature.NOTIFICATION).phoneFeature)
+ assertEquals(initialNotificationStatus, store.state.websitePermissionsState.getValue(PhoneFeature.NOTIFICATION).status)
+ assertEquals(defaultVisibilityStatus, store.state.websitePermissionsState.getValue(PhoneFeature.NOTIFICATION).isVisible)
+ assertEquals(defaultEnabledStatus, store.state.websitePermissionsState.getValue(PhoneFeature.NOTIFICATION).isEnabled)
+ assertEquals(defaultBlockedByAndroidStatus, store.state.websitePermissionsState.getValue(PhoneFeature.NOTIFICATION).isBlockedByAndroid)
+
+ assertNotNull(store.state.websitePermissionsState[PhoneFeature.LOCATION])
+ assertEquals(PhoneFeature.LOCATION, store.state.websitePermissionsState.getValue(PhoneFeature.LOCATION).phoneFeature)
+ assertEquals(initialLocationStatus, store.state.websitePermissionsState.getValue(PhoneFeature.LOCATION).status)
+ assertEquals(defaultVisibilityStatus, store.state.websitePermissionsState.getValue(PhoneFeature.LOCATION).isVisible)
+ assertEquals(defaultEnabledStatus, store.state.websitePermissionsState.getValue(PhoneFeature.LOCATION).isEnabled)
+ assertEquals(defaultBlockedByAndroidStatus, store.state.websitePermissionsState.getValue(PhoneFeature.LOCATION).isBlockedByAndroid)
+ }
+
+ @Test
+ fun `createTrackingProtectionState constructs a TrackingProtectionState with the right values`() {
+ val tab = createTab("https://www.firefox.com")
+ val browserStore = BrowserStore(BrowserState(tabs = listOf(tab)))
+ val isTrackingProtectionEnabled = true
+ val cookieBannerMode = CookieBannerUIMode.ENABLE
+
+ every { context.components.core.store } returns browserStore
+
+ val state = QuickSettingsFragmentStore.createTrackingProtectionState(
+ context = context,
+ websiteUrl = tab.content.url,
+ sessionId = tab.id,
+ isTrackingProtectionEnabled = isTrackingProtectionEnabled,
+ cookieBannerUIMode = CookieBannerUIMode.ENABLE,
+ )
+
+ assertNotNull(state)
+ assertEquals(tab, state.tab)
+ assertEquals(tab.content.url, state.url)
+ assertEquals(isTrackingProtectionEnabled, state.isTrackingProtectionEnabled)
+ assertEquals(cookieBannerMode, state.cookieBannerUIMode)
+ assertEquals(0, state.listTrackers.size)
+ assertEquals(ProtectionsState.Mode.Normal, state.mode)
+ assertEquals("", state.lastAccessedCategory)
+ }
+
+ private fun getRules() = SitePermissionsRules(
+ camera = Action.ASK_TO_ALLOW,
+ location = Action.ASK_TO_ALLOW,
+ microphone = Action.ASK_TO_ALLOW,
+ notification = Action.ASK_TO_ALLOW,
+ autoplayAudible = AutoplayAction.BLOCKED,
+ autoplayInaudible = AutoplayAction.BLOCKED,
+ persistentStorage = Action.ASK_TO_ALLOW,
+ mediaKeySystemAccess = Action.ASK_TO_ALLOW,
+ crossOriginStorageAccess = Action.ASK_TO_ALLOW,
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsInteractorTest.kt
new file mode 100644
index 0000000000..c9adbf05a6
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsInteractorTest.kt
@@ -0,0 +1,103 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.quicksettings
+
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class QuickSettingsInteractorTest {
+ private val controller = mockk<QuickSettingsController>(relaxed = true)
+ private val interactor = QuickSettingsInteractor(controller)
+
+ @Test
+ fun `onPermissionsShown should delegate the controller`() {
+ interactor.onPermissionsShown()
+
+ verify {
+ controller.handlePermissionsShown()
+ }
+ }
+
+ @Test
+ fun `onPermissionToggled should delegate the controller`() {
+ val websitePermission = mockk<WebsitePermission>()
+ val permission = slot<WebsitePermission>()
+
+ interactor.onPermissionToggled(websitePermission)
+
+ verify {
+ controller.handlePermissionToggled(capture(permission))
+ }
+
+ assertTrue(permission.isCaptured)
+ assertEquals(websitePermission, permission.captured)
+ }
+
+ @Test
+ fun `onAutoplayChanged should delegate the controller`() {
+ val websitePermission = mockk<AutoplayValue>()
+ val permission = slot<AutoplayValue>()
+
+ interactor.onAutoplayChanged(websitePermission)
+
+ verify {
+ controller.handleAutoplayChanged(capture(permission))
+ }
+
+ assertTrue(permission.isCaptured)
+ assertEquals(websitePermission, permission.captured)
+ }
+
+ @Test
+ fun `onTrackingProtectionToggled should delegate the controller`() {
+ val isEnabled = true
+
+ interactor.onTrackingProtectionToggled(isEnabled)
+
+ verify {
+ controller.handleTrackingProtectionToggled(isEnabled)
+ }
+ }
+
+ @Test
+ fun `onCookieBannerHandlingClicked should delegate the controller`() {
+ interactor.onCookieBannerHandlingDetailsClicked()
+
+ verify {
+ controller.handleCookieBannerHandlingDetailsClicked()
+ }
+ }
+
+ @Test
+ fun `onBlockedItemsClicked should delegate the controller`() {
+ interactor.onTrackingProtectionDetailsClicked()
+
+ verify {
+ controller.handleTrackingProtectionDetailsClicked()
+ }
+ }
+
+ @Test
+ fun `WHEN calling onConnectionDetailsClicked THEN delegate to the controller`() {
+ interactor.onConnectionDetailsClicked()
+
+ verify {
+ controller.handleConnectionDetailsClicked()
+ }
+ }
+
+ @Test
+ fun `WHEN calling onClearSiteDataClicked THEN delegate to the controller`() {
+ interactor.onClearSiteDataClicked("baseDomain")
+
+ verify {
+ controller.handleClearSiteDataClicked("baseDomain")
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsSheetDialogFragmentTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsSheetDialogFragmentTest.kt
new file mode 100644
index 0000000000..328bee295c
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/QuickSettingsSheetDialogFragmentTest.kt
@@ -0,0 +1,171 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.quicksettings
+
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.spyk
+import io.mockk.verify
+import junit.framework.TestCase.assertNotSame
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.action.TrackingProtectionAction.TrackerBlockedAction
+import mozilla.components.browser.state.action.TrackingProtectionAction.TrackerLoadedAction
+import mozilla.components.browser.state.selector.findTab
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.content.blocking.TrackerLog
+import mozilla.components.feature.session.TrackingProtectionUseCases
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.quicksettings.protections.ProtectionsView
+
+@RunWith(FenixRobolectricTestRunner::class)
+class QuickSettingsSheetDialogFragmentTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ private lateinit var lifecycleOwner: MockedLifecycleOwner
+ private lateinit var fragment: QuickSettingsSheetDialogFragment
+ private lateinit var store: BrowserStore
+
+ @Before
+ fun setup() {
+ fragment = spyk(QuickSettingsSheetDialogFragment())
+ lifecycleOwner = MockedLifecycleOwner(Lifecycle.State.STARTED)
+
+ store = BrowserStore()
+ every { fragment.view } returns mockk(relaxed = true)
+ every { fragment.lifecycle } returns lifecycleOwner.lifecycle
+ every { fragment.activity } returns mockk(relaxed = true)
+ }
+
+ @Test
+ fun `WHEN a tracker is loaded THEN trackers view is updated`() {
+ val tab = createTab("mozilla.org")
+
+ every { fragment.provideTabId() } returns tab.id
+ every { fragment.updateTrackers(any()) } returns Unit
+
+ fragment.observeTrackersChange(store)
+
+ addAndSelectTab(tab)
+
+ verify(exactly = 1) {
+ fragment.updateTrackers(tab)
+ }
+
+ store.dispatch(TrackerLoadedAction(tab.id, mockk())).joinBlocking()
+
+ val updatedTab = store.state.findTab(tab.id)!!
+
+ assertNotSame(updatedTab, tab)
+
+ verify(exactly = 1) {
+ fragment.updateTrackers(updatedTab)
+ }
+ }
+
+ @Test
+ fun `WHEN a tracker is blocked THEN trackers view is updated`() {
+ val tab = createTab("mozilla.org")
+
+ every { fragment.provideTabId() } returns tab.id
+ every { fragment.updateTrackers(any()) } returns Unit
+
+ fragment.observeTrackersChange(store)
+
+ addAndSelectTab(tab)
+
+ verify(exactly = 1) {
+ fragment.updateTrackers(tab)
+ }
+
+ store.dispatch(TrackerBlockedAction(tab.id, mockk())).joinBlocking()
+
+ val updatedTab = store.state.findTab(tab.id)!!
+
+ assertNotSame(updatedTab, tab)
+
+ verify(exactly = 1) {
+ fragment.updateTrackers(updatedTab)
+ }
+ }
+
+ @Test
+ fun `GIVEN no trackers WHEN calling updateTrackers THEN hide the details section`() {
+ val tab = createTab("mozilla.org")
+ val trackingProtectionUseCases: TrackingProtectionUseCases = mockk(relaxed = true)
+ val protectionsView: ProtectionsView = mockk(relaxed = true)
+
+ val onComplete = slot<(List<TrackerLog>) -> Unit>()
+
+ every { fragment.protectionsView } returns protectionsView
+
+ every {
+ trackingProtectionUseCases.fetchTrackingLogs.invoke(
+ any(),
+ capture(onComplete),
+ any(),
+ )
+ }.answers { onComplete.captured.invoke(emptyList()) }
+
+ every { fragment.provideTrackingProtectionUseCases() } returns trackingProtectionUseCases
+
+ fragment.updateTrackers(tab)
+
+ verify {
+ protectionsView.updateDetailsSection(false)
+ }
+ }
+
+ @Test
+ fun `GIVEN trackers WHEN calling updateTrackers THEN show the details section`() {
+ val tab = createTab("mozilla.org")
+ val trackingProtectionUseCases: TrackingProtectionUseCases = mockk(relaxed = true)
+ val protectionsView: ProtectionsView = mockk(relaxed = true)
+
+ val onComplete = slot<(List<TrackerLog>) -> Unit>()
+
+ every { fragment.protectionsView } returns protectionsView
+
+ every {
+ trackingProtectionUseCases.fetchTrackingLogs.invoke(
+ any(),
+ capture(onComplete),
+ any(),
+ )
+ }.answers { onComplete.captured.invoke(listOf(TrackerLog(""))) }
+
+ every { fragment.provideTrackingProtectionUseCases() } returns trackingProtectionUseCases
+
+ fragment.updateTrackers(tab)
+
+ verify {
+ protectionsView.updateDetailsSection(true)
+ }
+ }
+
+ private fun addAndSelectTab(tab: TabSessionState) {
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ store.dispatch(TabListAction.SelectTabAction(tab.id)).joinBlocking()
+ }
+
+ internal class MockedLifecycleOwner(initialState: Lifecycle.State) : LifecycleOwner {
+ override val lifecycle: Lifecycle = LifecycleRegistry(this).apply {
+ currentState = initialState
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/WebsiteInfoViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/WebsiteInfoViewTest.kt
new file mode 100644
index 0000000000..b68d57d442
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/WebsiteInfoViewTest.kt
@@ -0,0 +1,73 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.quicksettings
+
+import android.widget.FrameLayout
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.browser.icons.BrowserIcons
+import mozilla.components.browser.icons.IconRequest
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.databinding.QuicksettingsWebsiteInfoBinding
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class WebsiteInfoViewTest {
+
+ private lateinit var view: WebsiteInfoView
+ private lateinit var icons: BrowserIcons
+ private lateinit var binding: QuicksettingsWebsiteInfoBinding
+ private lateinit var interactor: WebSiteInfoInteractor
+
+ @Before
+ fun setup() {
+ icons = mockk(relaxed = true)
+ interactor = mockk(relaxed = true)
+ view = spyk(WebsiteInfoView(FrameLayout(testContext), icons, interactor))
+ binding = view.binding
+ every { icons.loadIntoView(any(), any()) } returns mockk()
+ }
+
+ @Test
+ fun `WHEN updating THEN bind url`() {
+ val websiteUrl = "https://mozilla.org"
+
+ view.update(
+ WebsiteInfoState(
+ websiteUrl = websiteUrl,
+ websiteTitle = "Mozilla",
+ websiteSecurityUiValues = WebsiteSecurityUiValues.SECURE,
+ certificateName = "",
+ ),
+ )
+
+ verify { icons.loadIntoView(binding.faviconImage, IconRequest(websiteUrl)) }
+
+ assertEquals("mozilla.org", binding.url.text)
+ assertEquals("Connection is secure", binding.securityInfo.text)
+ }
+
+ @Test
+ fun `WHEN updating THEN bind certificate`() {
+ view.update(
+ WebsiteInfoState(
+ websiteUrl = "https://mozilla.org",
+ websiteTitle = "Mozilla",
+ websiteSecurityUiValues = WebsiteSecurityUiValues.INSECURE,
+ certificateName = "Certificate",
+ ),
+ )
+
+ verify { view.bindConnectionDetailsListener() }
+
+ assertEquals("Connection is not secure", binding.securityInfo.text)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/WebsitePermissionViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/WebsitePermissionViewTest.kt
new file mode 100644
index 0000000000..48f5b4a396
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/WebsitePermissionViewTest.kt
@@ -0,0 +1,212 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.quicksettings
+
+import android.widget.FrameLayout
+import android.widget.TextView
+import androidx.appcompat.widget.AppCompatSpinner
+import androidx.core.view.isVisible
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import junit.framework.TestCase.assertEquals
+import junit.framework.TestCase.assertFalse
+import junit.framework.TestCase.assertTrue
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.PhoneFeature
+import org.mozilla.fenix.settings.quicksettings.WebsitePermissionsView.PermissionViewHolder.SpinnerPermission
+import org.mozilla.fenix.settings.quicksettings.WebsitePermissionsView.PermissionViewHolder.ToggleablePermission
+import java.util.EnumMap
+
+@RunWith(FenixRobolectricTestRunner::class)
+class WebsitePermissionViewTest {
+
+ @MockK(relaxed = true)
+ private lateinit var interactor: WebsitePermissionInteractor
+ private lateinit var view: WebsitePermissionsView
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ view = spyk(WebsitePermissionsView(FrameLayout(testContext), interactor))
+ }
+
+ @Test
+ fun `update - with visible permissions`() {
+ val label = TextView(testContext)
+ val status = TextView(testContext)
+ val permission = WebsitePermission.Toggleable(
+ phoneFeature = PhoneFeature.CAMERA,
+ status = "status",
+ isVisible = true,
+ isEnabled = true,
+ isBlockedByAndroid = false,
+ )
+
+ val map = mapOf<PhoneFeature, WebsitePermission>(PhoneFeature.CAMERA to permission)
+
+ view.permissionViews = EnumMap(
+ mapOf(PhoneFeature.CAMERA to ToggleablePermission(label, status)),
+ )
+
+ every { view.bindPermission(any(), any()) } returns Unit
+
+ view.update(map)
+
+ verify { interactor.onPermissionsShown() }
+ verify { view.bindPermission(any(), any()) }
+ }
+
+ @Test
+ fun `update - with none visible permissions`() {
+ val label = TextView(testContext)
+ val status = TextView(testContext)
+ val permission = WebsitePermission.Toggleable(
+ phoneFeature = PhoneFeature.CAMERA,
+ status = "status",
+ isVisible = false,
+ isEnabled = true,
+ isBlockedByAndroid = false,
+ )
+
+ val map = mapOf<PhoneFeature, WebsitePermission>(PhoneFeature.CAMERA to permission)
+
+ view.permissionViews =
+ EnumMap(mapOf(PhoneFeature.CAMERA to ToggleablePermission(label, status)))
+
+ every { view.bindPermission(any(), any()) } returns Unit
+
+ view.update(map)
+
+ verify(exactly = 0) { interactor.onPermissionsShown() }
+ verify { view.bindPermission(any(), any()) }
+ }
+
+ @Test
+ fun `bindPermission - a visible ToggleablePermission`() {
+ val label = TextView(testContext)
+ val status = TextView(testContext)
+ val permissionView = ToggleablePermission(label, status)
+ val permission = WebsitePermission.Toggleable(
+ phoneFeature = PhoneFeature.CAMERA,
+ status = "status",
+ isVisible = true,
+ isEnabled = true,
+ isBlockedByAndroid = false,
+ )
+
+ view.permissionViews = EnumMap(mapOf(PhoneFeature.CAMERA to permissionView))
+
+ every { interactor.onPermissionToggled(any()) } returns Unit
+
+ view.bindPermission(permission, permissionView)
+
+ assertTrue(permissionView.label.isVisible)
+ assertTrue(permissionView.label.isEnabled)
+ assertTrue(permissionView.status.isVisible)
+ assertEquals(permission.status, permissionView.status.text)
+
+ permissionView.status.performClick()
+
+ verify { interactor.onPermissionToggled(any()) }
+ }
+
+ @Test
+ fun `bindPermission - a not visible ToggleablePermission`() {
+ val label = TextView(testContext)
+ val status = TextView(testContext)
+ val permissionView = ToggleablePermission(label, status)
+ val permission = WebsitePermission.Toggleable(
+ phoneFeature = PhoneFeature.CAMERA,
+ status = "status",
+ isVisible = false,
+ isEnabled = false,
+ isBlockedByAndroid = false,
+ )
+
+ view.permissionViews = EnumMap(mapOf(PhoneFeature.CAMERA to permissionView))
+
+ every { interactor.onPermissionToggled(any()) } returns Unit
+
+ view.bindPermission(permission, permissionView)
+
+ assertFalse(permissionView.label.isVisible)
+ assertFalse(permissionView.label.isEnabled)
+ assertFalse(permissionView.status.isVisible)
+ assertEquals(permission.status, permissionView.status.text)
+
+ permissionView.status.performClick()
+
+ verify { interactor.onPermissionToggled(any()) }
+ }
+
+ @Test
+ fun `bindPermission - a visible SpinnerPermission`() {
+ val label = TextView(testContext)
+ val status = AppCompatSpinner(testContext)
+ val permissionView = SpinnerPermission(label, status)
+ val options = listOf(
+ AutoplayValue.BlockAll(
+ label = "BlockAll",
+ rules = mockk(),
+ sitePermission = null,
+ ),
+ AutoplayValue.AllowAll(
+ label = "AllowAll",
+ rules = mockk(),
+ sitePermission = null,
+ ),
+ AutoplayValue.BlockAudible(
+ label = "BlockAudible",
+ rules = mockk(),
+ sitePermission = null,
+ ),
+ )
+ val permission = WebsitePermission.Autoplay(
+ autoplayValue = options[0],
+ options = options,
+ isVisible = true,
+ )
+
+ view.permissionViews = EnumMap(mapOf(PhoneFeature.AUTOPLAY to permissionView))
+
+ every { interactor.onAutoplayChanged(any()) } returns Unit
+
+ view.bindPermission(permission, permissionView)
+
+ assertTrue(permissionView.label.isVisible)
+ assertFalse(permissionView.label.isEnabled)
+ assertTrue(permissionView.status.isVisible)
+ assertEquals(permission.autoplayValue, permissionView.status.selectedItem)
+
+ permissionView.status.onItemSelectedListener!!.onItemSelected(
+ mockk(),
+ permissionView.status,
+ 1,
+ 0L,
+ )
+
+ // Selecting the same item should not trigger a selection event.
+ verify(exactly = 0) { interactor.onAutoplayChanged(permissionView.status.selectedItem as AutoplayValue) }
+
+ permissionView.status.setSelection(2)
+ permissionView.status.onItemSelectedListener!!.onItemSelected(
+ mockk(),
+ permissionView.status,
+ 2,
+ 0L,
+ )
+
+ // Selecting a different item from the selected one should trigger an selection event.
+ verify(exactly = 1) { interactor.onAutoplayChanged(permissionView.status.selectedItem as AutoplayValue) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/ext/PhoneFeatureExtKtTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/ext/PhoneFeatureExtKtTest.kt
new file mode 100644
index 0000000000..30511630db
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/ext/PhoneFeatureExtKtTest.kt
@@ -0,0 +1,73 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.quicksettings.ext
+
+import android.content.Context
+import android.content.pm.PackageManager
+import io.mockk.every
+import io.mockk.mockk
+import mozilla.components.concept.engine.permission.SitePermissions
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.mozilla.fenix.settings.PhoneFeature
+
+class PhoneFeatureExtKtTest {
+ @Test
+ fun `shouldBeVisible returns if the user made a decision about the permission`() {
+ val noDecisionForPermission = mockk<SitePermissions>()
+ val userAllowedPermission = mockk<SitePermissions>()
+ val userBlockedPermission = mockk<SitePermissions>()
+ every { noDecisionForPermission.camera } returns SitePermissions.Status.NO_DECISION
+ every { userAllowedPermission.camera } returns SitePermissions.Status.ALLOWED
+ every { userBlockedPermission.camera } returns SitePermissions.Status.BLOCKED
+
+ assertFalse(PhoneFeature.CAMERA.shouldBeVisible(noDecisionForPermission, mockk()))
+ assertTrue(PhoneFeature.CAMERA.shouldBeVisible(userAllowedPermission, mockk()))
+ assertTrue(PhoneFeature.CAMERA.shouldBeVisible(userBlockedPermission, mockk()))
+ // The site doesn't have a site permission exception
+ assertFalse(PhoneFeature.CAMERA.shouldBeVisible(null, mockk()))
+ }
+
+ @Test
+ fun `isUserPermissionGranted returns if user allowed or denied a permission`() {
+ val noDecisionForPermission = mockk<SitePermissions>()
+ val userAllowedPermission = mockk<SitePermissions>()
+ val userBlockedPermission = mockk<SitePermissions>()
+ every { noDecisionForPermission.camera } returns SitePermissions.Status.NO_DECISION
+ every { userAllowedPermission.camera } returns SitePermissions.Status.ALLOWED
+ every { userBlockedPermission.camera } returns SitePermissions.Status.BLOCKED
+
+ assertTrue(PhoneFeature.CAMERA.isUserPermissionGranted(userAllowedPermission, mockk()))
+ assertFalse(PhoneFeature.CAMERA.isUserPermissionGranted(noDecisionForPermission, mockk()))
+ assertFalse(PhoneFeature.CAMERA.isUserPermissionGranted(userBlockedPermission, mockk()))
+ }
+
+ @Test
+ fun `shouldBeEnabled returns if permission is granted by user and Android`() {
+ val androidPermissionGrantedContext = mockk<Context>()
+ val androidPermissionDeniedContext = mockk<Context>()
+ val userAllowedPermission = mockk<SitePermissions>()
+ val noDecisionForPermission = mockk<SitePermissions>()
+ val userBlockedPermission = mockk<SitePermissions>()
+ every { androidPermissionGrantedContext.checkPermission(any(), any(), any()) }
+ .returns(PackageManager.PERMISSION_GRANTED)
+ every { androidPermissionDeniedContext.checkPermission(any(), any(), any()) }
+ .returns(PackageManager.PERMISSION_DENIED)
+ every { userAllowedPermission.camera } returns SitePermissions.Status.ALLOWED
+ every { noDecisionForPermission.camera } returns SitePermissions.Status.NO_DECISION
+ every { userBlockedPermission.camera } returns SitePermissions.Status.BLOCKED
+
+ // Check result for when the Android permission is granted to the app
+ assertTrue(PhoneFeature.CAMERA.shouldBeEnabled(androidPermissionGrantedContext, userAllowedPermission, mockk()))
+ assertFalse(PhoneFeature.CAMERA.shouldBeEnabled(androidPermissionGrantedContext, noDecisionForPermission, mockk()))
+ assertFalse(PhoneFeature.CAMERA.shouldBeEnabled(androidPermissionGrantedContext, userBlockedPermission, mockk()))
+
+ // Check result for when the Android permission is denied to the app
+ assertFalse(PhoneFeature.CAMERA.shouldBeEnabled(androidPermissionDeniedContext, userAllowedPermission, mockk()))
+ assertFalse(PhoneFeature.CAMERA.shouldBeEnabled(androidPermissionDeniedContext, noDecisionForPermission, mockk()))
+ assertFalse(PhoneFeature.CAMERA.shouldBeEnabled(androidPermissionDeniedContext, userBlockedPermission, mockk()))
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/protections/cookiebanners/CookieBannerHandlingDetailsViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/protections/cookiebanners/CookieBannerHandlingDetailsViewTest.kt
new file mode 100644
index 0000000000..5afd82b0f2
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/protections/cookiebanners/CookieBannerHandlingDetailsViewTest.kt
@@ -0,0 +1,255 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.quicksettings.protections.cookiebanners
+
+import android.view.View
+import android.widget.FrameLayout
+import io.mockk.MockKAnnotations
+import io.mockk.coEvery
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.test.advanceUntilIdle
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.lib.publicsuffixlist.PublicSuffixList
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.databinding.ComponentCookieBannerDetailsPanelBinding
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.trackingprotection.CookieBannerUIMode
+import org.mozilla.fenix.trackingprotection.ProtectionsState
+
+@RunWith(FenixRobolectricTestRunner::class)
+class CookieBannerHandlingDetailsViewTest {
+
+ private lateinit var view: CookieBannerHandlingDetailsView
+ private lateinit var binding: ComponentCookieBannerDetailsPanelBinding
+ private lateinit var interactor: CookieBannerDetailsInteractor
+
+ @MockK(relaxed = true)
+ private lateinit var publicSuffixList: PublicSuffixList
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val scope = coroutinesTestRule.scope
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ interactor = mockk(relaxed = true)
+ view = spyk(
+ CookieBannerHandlingDetailsView(
+ container = FrameLayout(testContext),
+ context = testContext,
+ publicSuffixList = publicSuffixList,
+ interactor = interactor,
+ ioScope = scope,
+ onDismiss = {},
+ ),
+ )
+ binding = view.binding
+ }
+
+ @Test
+ fun `WHEN updating THEN bind title,back button, description and switch`() {
+ val websiteUrl = "https://mozilla.org"
+ val state = ProtectionsState(
+ tab = createTab(url = websiteUrl),
+ url = websiteUrl,
+ isTrackingProtectionEnabled = true,
+ cookieBannerUIMode = CookieBannerUIMode.ENABLE,
+ listTrackers = listOf(),
+ mode = ProtectionsState.Mode.Normal,
+ lastAccessedCategory = "",
+ )
+
+ view.update(state)
+
+ verify {
+ view.bindTitle(state.url, state.cookieBannerUIMode)
+ view.bindBackButtonListener()
+ view.bindDescription(state.cookieBannerUIMode)
+ view.bindSwitch(state.cookieBannerUIMode)
+ }
+ }
+
+ @Test
+ fun `GIVEN cookie banner handling mode is enabled WHEN biding title THEN title view must have the expected string`() =
+ runTestOnMain {
+ coEvery { publicSuffixList.getPublicSuffixPlusOne(any()) } returns CompletableDeferred("mozilla.org")
+
+ val websiteUrl = "https://mozilla.org"
+
+ view.bindTitle(url = websiteUrl, state = CookieBannerUIMode.ENABLE)
+
+ val expectedText =
+ testContext.getString(
+ R.string.reduce_cookie_banner_details_panel_title_off_for_site_1,
+ "mozilla.org",
+ )
+
+ assertEquals(expectedText, view.binding.title.text)
+ }
+
+ @Test
+ fun `GIVEN cookie banner handling mode is site not supported WHEN biding title THEN title view must have the expected string`() =
+ runTestOnMain {
+ coEvery { publicSuffixList.getPublicSuffixPlusOne(any()) } returns CompletableDeferred("mozilla.org")
+
+ val websiteUrl = "https://mozilla.org"
+
+ view.bindTitle(url = websiteUrl, state = CookieBannerUIMode.SITE_NOT_SUPPORTED)
+
+ val expectedText =
+ testContext.getString(
+ R.string.cookie_banner_handling_details_site_is_not_supported_title_2,
+ )
+
+ assertEquals(expectedText, view.binding.title.text)
+ }
+
+ @Test
+ fun `GIVEN cookie banner handling mode is disabled WHEN biding title THEN title view must have the expected string`() =
+ runTestOnMain {
+ coEvery { publicSuffixList.getPublicSuffixPlusOne(any()) } returns CompletableDeferred("mozilla.org")
+
+ val websiteUrl = "https://mozilla.org"
+
+ view.bindTitle(
+ url = websiteUrl,
+ state = CookieBannerUIMode.DISABLE,
+ )
+
+ advanceUntilIdle()
+
+ val expectedText =
+ testContext.getString(
+ R.string.reduce_cookie_banner_details_panel_title_on_for_site_1,
+ "mozilla.org",
+ )
+
+ assertEquals(expectedText, view.binding.title.text)
+ }
+
+ @Test
+ fun `WHEN clicking the back button THEN view must delegate to the interactor#onBackPressed()`() {
+ view.bindBackButtonListener()
+
+ view.binding.navigateBack.performClick()
+
+ verify {
+ interactor.onBackPressed()
+ }
+ }
+
+ @Test
+ fun `GIVEN cookie banner handling mode is enabled WHEN biding description THEN description view must have the expected string`() {
+ view.bindDescription(state = CookieBannerUIMode.ENABLE)
+
+ val expectedText =
+ testContext.getString(
+ R.string.reduce_cookie_banner_details_panel_description_off_for_site_1,
+ testContext.getString(R.string.app_name),
+ )
+
+ assertEquals(expectedText, view.binding.details.text)
+ }
+
+ @Test
+ fun `GIVEN cookie banner handling mode is site not supported WHEN biding description THEN description view must have the expected string`() {
+ view.bindDescription(state = CookieBannerUIMode.SITE_NOT_SUPPORTED)
+
+ val appName = testContext.getString(R.string.app_name)
+ val expectedText =
+ testContext.getString(
+ R.string.reduce_cookie_banner_details_panel_title_unsupported_site_request_2,
+ appName,
+ )
+
+ assertEquals(expectedText, view.binding.details.text)
+ }
+
+ @Test
+ fun `GIVEN cookie banner handling mode is disabled WHEN biding description THEN description view must have the expected string`() {
+ view.bindDescription(state = CookieBannerUIMode.DISABLE)
+
+ val appName = testContext.getString(R.string.app_name)
+ val expectedText =
+ testContext.getString(
+ R.string.reduce_cookie_banner_details_panel_description_on_for_site_3,
+ appName,
+ appName,
+ )
+
+ assertEquals(expectedText, view.binding.details.text)
+ }
+
+ @Test
+ fun `GIVEN cookie banner handling is disabled WHEN biding switch THEN switch view must have the expected isChecked status`() {
+ view.bindSwitch(state = CookieBannerUIMode.DISABLE)
+
+ assertFalse(view.binding.cookieBannerSwitch.isChecked)
+ }
+
+ @Test
+ fun `GIVEN cookie banner handling is enabled WHEN biding switch THEN switch view must have the expected isChecked status`() {
+ view.bindSwitch(state = CookieBannerUIMode.ENABLE)
+
+ assertTrue(view.binding.cookieBannerSwitch.isChecked)
+ }
+
+ @Test
+ fun `GIVEN cookie banner handling is site not supported WHEN setUiForCookieBannerMode THEN set ui for site not supported should be visible`() {
+ val websiteUrl = "https://mozilla.org"
+ val state = ProtectionsState(
+ tab = createTab(url = websiteUrl),
+ url = websiteUrl,
+ isTrackingProtectionEnabled = true,
+ cookieBannerUIMode = CookieBannerUIMode.SITE_NOT_SUPPORTED,
+ listTrackers = listOf(),
+ mode = ProtectionsState.Mode.Normal,
+ lastAccessedCategory = "",
+ )
+
+ view.update(state)
+
+ assertEquals(View.GONE, view.binding.cookieBannerSwitch.visibility)
+ assertEquals(View.VISIBLE, view.binding.cancelButton.visibility)
+ assertEquals(View.VISIBLE, view.binding.requestSupport.visibility)
+ }
+
+ @Test
+ fun `WHEN clicking the request support button THEN view must delegate to the interactor#handleRequestSiteSupportPressed()`() {
+ val websiteUrl = "https://mozilla.org"
+ val state = ProtectionsState(
+ tab = createTab(url = websiteUrl),
+ url = websiteUrl,
+ isTrackingProtectionEnabled = true,
+ cookieBannerUIMode = CookieBannerUIMode.SITE_NOT_SUPPORTED,
+ listTrackers = listOf(),
+ mode = ProtectionsState.Mode.Normal,
+ lastAccessedCategory = "",
+ )
+
+ view.update(state)
+
+ view.binding.requestSupport.performClick()
+
+ verify {
+ interactor.handleRequestSiteSupportPressed()
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/protections/cookiebanners/DefaultCookieBannerDetailsControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/protections/cookiebanners/DefaultCookieBannerDetailsControllerTest.kt
new file mode 100644
index 0000000000..963d6480b5
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/protections/cookiebanners/DefaultCookieBannerDetailsControllerTest.kt
@@ -0,0 +1,262 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.quicksettings.protections.cookiebanners
+
+import android.content.Context
+import androidx.fragment.app.Fragment
+import androidx.navigation.NavController
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.coVerifyOrder
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.spyk
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.test.advanceUntilIdle
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.createCustomTab
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.Engine
+import mozilla.components.concept.engine.cookiehandling.CookieBannersStorage
+import mozilla.components.concept.engine.permission.SitePermissions
+import mozilla.components.feature.session.SessionUseCases
+import mozilla.components.feature.session.TrackingProtectionUseCases
+import mozilla.components.lib.publicsuffixlist.PublicSuffixList
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.CookieBanners
+import org.mozilla.fenix.GleanMetrics.Pings
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.trackingprotection.CookieBannerUIMode
+import org.mozilla.fenix.trackingprotection.ProtectionsAction
+import org.mozilla.fenix.trackingprotection.ProtectionsStore
+
+@RunWith(FenixRobolectricTestRunner::class)
+internal class DefaultCookieBannerDetailsControllerTest {
+
+ private lateinit var context: Context
+
+ @MockK(relaxed = true)
+ private lateinit var navController: NavController
+
+ @MockK(relaxed = true)
+ private lateinit var fragment: Fragment
+
+ @MockK(relaxed = true)
+ private lateinit var sitePermissions: SitePermissions
+
+ @MockK(relaxed = true)
+ private lateinit var cookieBannersStorage: CookieBannersStorage
+
+ private lateinit var controller: DefaultCookieBannerDetailsController
+
+ private lateinit var tab: TabSessionState
+
+ private lateinit var browserStore: BrowserStore
+
+ @MockK(relaxed = true)
+ private lateinit var protectionsStore: ProtectionsStore
+
+ @MockK(relaxed = true)
+ private lateinit var reload: SessionUseCases.ReloadUrlUseCase
+
+ @MockK(relaxed = true)
+ private lateinit var engine: Engine
+
+ @MockK(relaxed = true)
+ private lateinit var publicSuffixList: PublicSuffixList
+
+ private var gravity = 54
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val scope = coroutinesTestRule.scope
+
+ @get:Rule
+ val gleanRule = GleanTestRule(testContext)
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+ val trackingProtectionUseCases: TrackingProtectionUseCases = mockk(relaxed = true)
+ context = spyk(testContext)
+ tab = createTab("https://mozilla.org")
+ browserStore = BrowserStore(BrowserState(tabs = listOf(tab)))
+ controller = spyk(
+ DefaultCookieBannerDetailsController(
+ fragment = fragment,
+ context = context,
+ ioScope = scope,
+ cookieBannersStorage = cookieBannersStorage,
+ navController = { navController },
+ sitePermissions = sitePermissions,
+ gravity = gravity,
+ getCurrentTab = { tab },
+ sessionId = tab.id,
+ browserStore = browserStore,
+ protectionsStore = protectionsStore,
+ engine = engine,
+ publicSuffixList = publicSuffixList,
+ reload = reload,
+ ),
+ )
+
+ every { fragment.context } returns context
+ every { context.components.useCases.trackingProtectionUseCases } returns trackingProtectionUseCases
+
+ val onComplete = slot<(Boolean) -> Unit>()
+ every {
+ trackingProtectionUseCases.containsException.invoke(
+ any(),
+ capture(onComplete),
+ )
+ }.answers { onComplete.captured.invoke(true) }
+ }
+
+ @Test
+ fun `WHEN handleBackPressed is called THEN should call popBackStack and navigate`() = runTestOnMain {
+ every { context.settings().shouldUseCookieBannerPrivateMode } returns false
+
+ controller.handleBackPressed()
+
+ coVerify {
+ navController.popBackStack()
+ }
+ }
+
+ @Test
+ fun `GIVEN cookie banner is enabled WHEN handleTogglePressed THEN remove from the storage, send telemetry and reload the tab`() =
+ runTestOnMain {
+ val cookieBannerUIMode = CookieBannerUIMode.ENABLE
+
+ assertNull(CookieBanners.exceptionRemoved.testGetValue())
+ every { protectionsStore.dispatch(any()) } returns mockk()
+
+ controller.handleTogglePressed(true)
+
+ advanceUntilIdle()
+
+ coVerifyOrder {
+ cookieBannersStorage.removeException(
+ uri = tab.content.url,
+ privateBrowsing = tab.content.private,
+ )
+ protectionsStore.dispatch(
+ ProtectionsAction.ToggleCookieBannerHandlingProtectionEnabled(
+ cookieBannerUIMode,
+ ),
+ )
+ reload(tab.id)
+ }
+
+ assertNotNull(CookieBanners.exceptionRemoved.testGetValue())
+ }
+
+ @Test
+ fun `GIVEN cookie banner is disabled WHEN handleTogglePressed THEN remove from the storage, send telemetry and reload the tab`() =
+ runTestOnMain {
+ val cookieBannerUIMode = CookieBannerUIMode.DISABLE
+
+ assertNull(CookieBanners.exceptionRemoved.testGetValue())
+ every { protectionsStore.dispatch(any()) } returns mockk()
+ coEvery { controller.clearSiteData(any()) } just Runs
+
+ controller.handleTogglePressed(false)
+
+ advanceUntilIdle()
+
+ coVerifyOrder {
+ controller.clearSiteData(tab)
+ cookieBannersStorage.addException(
+ uri = tab.content.url,
+ privateBrowsing = tab.content.private,
+ )
+ protectionsStore.dispatch(
+ ProtectionsAction.ToggleCookieBannerHandlingProtectionEnabled(
+ cookieBannerUIMode,
+ ),
+ )
+ reload(tab.id)
+ }
+
+ assertNotNull(CookieBanners.exceptionAdded.testGetValue())
+ }
+
+ @Test
+ fun `WHEN clearSiteData THEN delegate the call to the engine`() =
+ runTestOnMain {
+ coEvery { publicSuffixList.getPublicSuffixPlusOne(any()) } returns CompletableDeferred("mozilla.org")
+
+ controller.clearSiteData(tab)
+
+ coVerifyOrder {
+ engine.clearData(
+ host = "mozilla.org",
+ data = Engine.BrowsingData.select(
+ Engine.BrowsingData.AUTH_SESSIONS,
+ Engine.BrowsingData.ALL_SITE_DATA,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN cookie banner mode is site not supported WHEN handleRequestSiteSupportPressed THEN request report site domain`() =
+ runTestOnMain {
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(
+ createCustomTab(
+ url = "https://www.mozilla.org",
+ id = "mozilla",
+ ),
+ ),
+ ),
+ )
+ every { testContext.components.core.store } returns store
+ coEvery { controller.getTabDomain(any()) } returns "mozilla.org"
+ every { protectionsStore.dispatch(any()) } returns mockk()
+
+ controller.handleRequestSiteSupportPressed()
+
+ assertNotNull(CookieBanners.reportDomainSiteButton.testGetValue())
+ Pings.cookieBannerReportSite.testBeforeNextSubmit {
+ assertNotNull(CookieBanners.reportSiteDomain.testGetValue())
+ assertEquals("mozilla.org", CookieBanners.reportSiteDomain.testGetValue())
+ }
+ advanceUntilIdle()
+ coVerifyOrder {
+ protectionsStore.dispatch(
+ ProtectionsAction.RequestReportSiteDomain(
+ "mozilla.org",
+ ),
+ )
+ protectionsStore.dispatch(
+ ProtectionsAction.UpdateCookieBannerMode(
+ cookieBannerUIMode = CookieBannerUIMode.REQUEST_UNSUPPORTED_SITE_SUBMITTED,
+ ),
+ )
+ cookieBannersStorage.saveSiteDomain("mozilla.org")
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/protections/cookiebanners/DefaultCookieBannerDetailsInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/protections/cookiebanners/DefaultCookieBannerDetailsInteractorTest.kt
new file mode 100644
index 0000000000..9be439a65f
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/quicksettings/protections/cookiebanners/DefaultCookieBannerDetailsInteractorTest.kt
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.quicksettings.protections.cookiebanners
+
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+
+class DefaultCookieBannerDetailsInteractorTest {
+ private lateinit var controller: CookieBannerDetailsController
+ private lateinit var interactor: DefaultCookieBannerDetailsInteractor
+
+ @Before
+ fun setUp() {
+ controller = mockk(relaxed = true)
+ interactor = DefaultCookieBannerDetailsInteractor(controller)
+ }
+
+ @Test
+ fun `WHEN onBackPressed is called THEN delegate the controller`() {
+ interactor.onBackPressed()
+
+ verify {
+ controller.handleBackPressed()
+ }
+ }
+
+ @Test
+ fun `WHEN onTogglePressed is called THEN delegate the controller`() {
+ interactor.onTogglePressed(true)
+
+ verify {
+ controller.handleTogglePressed(true)
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/search/SearchEngineFragmentTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/search/SearchEngineFragmentTest.kt
new file mode 100644
index 0000000000..70a8676191
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/search/SearchEngineFragmentTest.kt
@@ -0,0 +1,133 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.search
+
+import android.content.SharedPreferences
+import androidx.preference.CheckBoxPreference
+import androidx.preference.Preference
+import androidx.preference.SwitchPreference
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.mockkStatic
+import io.mockk.spyk
+import io.mockk.unmockkObject
+import io.mockk.verify
+import mozilla.components.browser.state.state.SearchState
+import mozilla.components.browser.state.state.selectedOrDefaultSearchEngine
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.R
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.utils.Settings
+import org.mozilla.gecko.search.SearchWidgetProvider
+
+@RunWith(FenixRobolectricTestRunner::class)
+class SearchEngineFragmentTest {
+ @Test
+ fun `GIVEN pref_key_show_voice_search setting WHEN it is modified THEN the value is persisted and widgets updated`() {
+ try {
+ val settings = mockk<Settings>(relaxed = true)
+ every { settings.preferences }
+ every { testContext.components.settings } returns settings
+ val preferences: SharedPreferences = mockk()
+ val preferencesEditor: SharedPreferences.Editor = mockk(relaxed = true)
+ every { settings.preferences } returns preferences
+ every { preferences.edit() } returns preferencesEditor
+
+ mockkObject(SearchWidgetProvider.Companion)
+
+ val fragment = spyk(SearchEngineFragment()) {
+ every { context } returns testContext
+ every { isAdded } returns true
+ every { activity } returns mockk<HomeActivity>(relaxed = true)
+ }
+ val voiceSearchPreferenceKey = testContext.getString(R.string.pref_key_show_voice_search)
+ val voiceSearchPreference = spyk(SwitchPreference(testContext)) {
+ every { key } returns voiceSearchPreferenceKey
+ }
+ // The type needed for "fragment.findPreference" / "fragment.requirePreference" is erased at compile time.
+ // Hence we need individual mocks, specific for each preference's type.
+ every {
+ fragment.findPreference<SwitchPreference>(testContext.getString(R.string.pref_key_show_search_suggestions))
+ } returns mockk(relaxed = true) {
+ every { context } returns testContext
+ }
+ every {
+ fragment.findPreference<SwitchPreference>(testContext.getString(R.string.pref_key_enable_autocomplete_urls))
+ } returns mockk(relaxed = true) {
+ every { context } returns testContext
+ }
+ every {
+ fragment.findPreference<CheckBoxPreference>(testContext.getString(R.string.pref_key_show_search_suggestions_in_private))
+ } returns mockk(relaxed = true) {
+ every { context } returns testContext
+ }
+ every {
+ fragment.findPreference<SwitchPreference>(testContext.getString(R.string.pref_key_search_browsing_history))
+ } returns mockk(relaxed = true) {
+ every { context } returns testContext
+ }
+ every {
+ fragment.findPreference<SwitchPreference>(testContext.getString(R.string.pref_key_search_bookmarks))
+ } returns mockk(relaxed = true) {
+ every { context } returns testContext
+ }
+ every {
+ fragment.findPreference<SwitchPreference>(testContext.getString(R.string.pref_key_search_synced_tabs))
+ } returns mockk(relaxed = true) {
+ every { context } returns testContext
+ }
+ every {
+ fragment.findPreference<SwitchPreference>(testContext.getString(R.string.pref_key_show_clipboard_suggestions))
+ } returns mockk(relaxed = true) {
+ every { context } returns testContext
+ }
+ every {
+ fragment.findPreference<SwitchPreference>(testContext.getString(R.string.pref_key_show_sponsored_suggestions))
+ } returns mockk(relaxed = true) {
+ every { context } returns testContext
+ }
+ every {
+ fragment.findPreference<SwitchPreference>(testContext.getString(R.string.pref_key_show_nonsponsored_suggestions))
+ } returns mockk(relaxed = true) {
+ every { context } returns testContext
+ }
+ every {
+ fragment.findPreference<Preference>(testContext.getString(R.string.pref_key_learn_about_fx_suggest))
+ } returns mockk(relaxed = true) {
+ every { context } returns testContext
+ }
+ // This preference is the sole purpose of this test
+ every {
+ fragment.findPreference<SwitchPreference>(voiceSearchPreferenceKey)
+ } returns voiceSearchPreference
+ every {
+ fragment.findPreference<Preference>(testContext.getString(R.string.pref_key_default_search_engine))
+ } returns mockk(relaxed = true) {
+ every { context } returns testContext
+
+ val searchEngineName = "MySearchEngine"
+ mockkStatic("mozilla.components.browser.state.state.SearchStateKt")
+ every { testContext.components.core.store.state.search } returns mockk(relaxed = true)
+ every { any<SearchState>().selectedOrDefaultSearchEngine } returns mockk {
+ every { name } returns searchEngineName
+ }
+ }
+
+ // Trigger the preferences setup.
+ fragment.onResume()
+ voiceSearchPreference.callChangeListener(true)
+
+ verify { preferencesEditor.putBoolean(voiceSearchPreferenceKey, true) }
+ verify { SearchWidgetProvider.updateAllWidgets(testContext) }
+ } finally {
+ unmockkObject(SearchWidgetProvider.Companion)
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/search/SearchStringValidatorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/search/SearchStringValidatorTest.kt
new file mode 100644
index 0000000000..9935648afa
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/search/SearchStringValidatorTest.kt
@@ -0,0 +1,104 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.search
+
+import io.mockk.every
+import io.mockk.mockk
+import mozilla.components.concept.fetch.Client
+import mozilla.components.concept.fetch.MutableHeaders
+import mozilla.components.concept.fetch.Request
+import mozilla.components.concept.fetch.Response
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import java.io.ByteArrayInputStream
+import java.io.IOException
+
+@RunWith(FenixRobolectricTestRunner::class)
+class SearchStringValidatorTest {
+
+ private val client: Client = mockk()
+
+ @Test
+ fun `test MDN url`() {
+ val request = Request(
+ url = "https://developer.mozilla.org/en-US/search?q=1",
+ )
+ every { client.fetch(request) } returns Response(
+ url = "",
+ status = 200,
+ headers = MutableHeaders(),
+ body = Response.Body(ByteArrayInputStream("".toByteArray())),
+ )
+
+ val result = SearchStringValidator.isSearchStringValid(client, "https://developer.mozilla.org/en-US/search?q=%s")
+
+ assertEquals(SearchStringValidator.Result.Success, result)
+ }
+
+ @Test
+ fun `normalize search url`() {
+ val request = Request(
+ url = "http://firefox.com/search?q=1",
+ )
+ every { client.fetch(request) } returns Response(
+ url = "",
+ status = 200,
+ headers = MutableHeaders(),
+ body = Response.Body(ByteArrayInputStream("".toByteArray())),
+ )
+
+ val result = SearchStringValidator.isSearchStringValid(client, "firefox.com/search?q=%s")
+
+ assertEquals(SearchStringValidator.Result.Success, result)
+ }
+
+ @Test
+ fun `fail if IOException is thrown`() {
+ every { client.fetch(any()) } throws IOException()
+
+ val result = SearchStringValidator.isSearchStringValid(client, "https://developer.mozilla.org/en-US/search?q=%s")
+
+ assertEquals(SearchStringValidator.Result.CannotReach, result)
+ }
+
+ @Test
+ fun `fail if IllegalArgumentException is thrown`() {
+ every { client.fetch(any()) } throws IllegalArgumentException()
+
+ val result = SearchStringValidator.isSearchStringValid(client, "https://developer.mozilla.org/en-US/search?q=%s")
+
+ assertEquals(SearchStringValidator.Result.CannotReach, result)
+ }
+
+ @Test
+ fun `pass if status code is not in success range`() {
+ every { client.fetch(any()) } returns Response(
+ url = "",
+ status = 400,
+ headers = MutableHeaders(),
+ body = Response.Body(ByteArrayInputStream("".toByteArray())),
+ )
+
+ val result = SearchStringValidator.isSearchStringValid(client, "https://developer.mozilla.org/en-US/search?q=%s")
+
+ assertEquals(SearchStringValidator.Result.CannotReach, result)
+ }
+
+ @Test
+ fun `pass even if 404 status is returned`() {
+ every { client.fetch(any()) } returns Response(
+ url = "",
+ status = 404,
+ headers = MutableHeaders(),
+ body = Response.Body(ByteArrayInputStream("".toByteArray())),
+ )
+
+ val result = SearchStringValidator.isSearchStringValid(client, "https://developer.mozilla.org/en-US/search?q=%s")
+
+ assertEquals(SearchStringValidator.Result.Success, result)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/sitepermissions/SitePermissionsDetailsExceptionsFragmentTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/sitepermissions/SitePermissionsDetailsExceptionsFragmentTest.kt
new file mode 100644
index 0000000000..3c3f436594
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/sitepermissions/SitePermissionsDetailsExceptionsFragmentTest.kt
@@ -0,0 +1,109 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.sitepermissions
+
+import android.content.Context
+import androidx.preference.Preference
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.concept.engine.permission.SitePermissions
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.PhoneFeature
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class SitePermissionsDetailsExceptionsFragmentTest {
+ @MockK(relaxed = true)
+ private lateinit var settings: Settings
+
+ @MockK(relaxed = true)
+ private lateinit var permissions: SitePermissions
+
+ private lateinit var fragment: SitePermissionsDetailsExceptionsFragment
+ private lateinit var context: Context
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ context = spyk(testContext)
+ fragment = spyk(SitePermissionsDetailsExceptionsFragment())
+
+ fragment.sitePermissions = permissions
+
+ every { permissions.origin } returns "mozilla.org"
+ every { fragment.provideContext() } returns context
+ every { fragment.provideSettings() } returns settings
+ }
+
+ @Test
+ fun `WHEN bindCategoryPhoneFeatures is called THEN all categories must be initialized`() {
+ every { fragment.initPhoneFeature(any()) } returns Unit
+ every { fragment.initAutoplayFeature() } returns Unit
+ every { fragment.bindClearPermissionsButton() } returns Unit
+
+ fragment.bindCategoryPhoneFeatures()
+
+ verify {
+ fragment.initPhoneFeature(PhoneFeature.CAMERA)
+ fragment.initPhoneFeature(PhoneFeature.LOCATION)
+ fragment.initPhoneFeature(PhoneFeature.MICROPHONE)
+ fragment.initPhoneFeature(PhoneFeature.NOTIFICATION)
+ fragment.initPhoneFeature(PhoneFeature.PERSISTENT_STORAGE)
+ fragment.initPhoneFeature(PhoneFeature.MEDIA_KEY_SYSTEM_ACCESS)
+ fragment.initAutoplayFeature()
+ fragment.bindClearPermissionsButton()
+ }
+ }
+
+ @Test
+ fun `WHEN initPhoneFeature is called THEN the feature label must be bind and a click listener must be attached`() {
+ val feature = PhoneFeature.CAMERA
+ val label = "label"
+ val preference = Preference(context)
+
+ every { context.getString(R.string.phone_feature_blocked_by_android) } returns label
+ every { fragment.getPreference((any())) } returns preference
+ every { fragment.navigateToPhoneFeature((any())) } returns Unit
+
+ fragment.initPhoneFeature(feature)
+
+ assertEquals(label, preference.summary)
+
+ preference.performClick()
+
+ verify {
+ fragment.navigateToPhoneFeature(feature)
+ }
+ }
+
+ @Test
+ fun `WHEN initAutoplayFeature THEN the autoplay label must be bind and a click listener must be attached`() {
+ val label = "label"
+ val preference = Preference(context)
+
+ every { fragment.getAutoplayLabel() } returns label
+ every { fragment.getPreference((any())) } returns preference
+ every { fragment.navigateToPhoneFeature((any())) } returns Unit
+
+ fragment.initAutoplayFeature()
+
+ assertEquals(label, preference.summary)
+
+ preference.performClick()
+
+ verify {
+ fragment.navigateToPhoneFeature(PhoneFeature.AUTOPLAY)
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/sitepermissions/SitePermissionsManageExceptionsPhoneFeatureFragmentTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/sitepermissions/SitePermissionsManageExceptionsPhoneFeatureFragmentTest.kt
new file mode 100644
index 0000000000..a87c09e402
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/sitepermissions/SitePermissionsManageExceptionsPhoneFeatureFragmentTest.kt
@@ -0,0 +1,544 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.sitepermissions
+
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.RadioButton
+import androidx.core.view.isVisible
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.concept.engine.permission.SitePermissions
+import mozilla.components.concept.engine.permission.SitePermissions.AutoplayStatus
+import mozilla.components.feature.sitepermissions.SitePermissionsRules
+import mozilla.components.feature.sitepermissions.SitePermissionsRules.Action
+import mozilla.components.feature.sitepermissions.SitePermissionsRules.AutoplayAction
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.PhoneFeature
+import org.mozilla.fenix.settings.quicksettings.AutoplayValue
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class SitePermissionsManageExceptionsPhoneFeatureFragmentTest {
+ @MockK(relaxed = true)
+ private lateinit var settings: Settings
+
+ @MockK(relaxed = true)
+ private lateinit var permissions: SitePermissions
+
+ private lateinit var fragment: SitePermissionsManageExceptionsPhoneFeatureFragment
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+
+ fragment = spyk(SitePermissionsManageExceptionsPhoneFeatureFragment())
+ fragment.rootView = mockk(relaxed = true)
+
+ every { fragment.requireContext() } returns testContext
+ every { fragment.getSettings() } returns settings
+ }
+
+ @Test
+ fun `GIVEN an AUTOPLAY permission WHEN onCreateView is called THEN initAutoplay is called`() {
+ every { fragment.getFeature() } returns PhoneFeature.AUTOPLAY
+ every { fragment.initAutoplay(permissions) } returns Unit
+ every { fragment.getSitePermission() } returns permissions
+
+ fragment.onCreateView(LayoutInflater.from(testContext), null, null)
+
+ verify {
+ fragment.initAutoplay(permissions)
+ fragment.bindBlockedByAndroidContainer()
+ fragment.initClearPermissionsButton()
+ }
+ }
+
+ @Test
+ fun `GIVEN a none AUTOPLAY permission WHEN onCreateView is called THEN initNormalFeature is called`() {
+ val features = PhoneFeature.values().filter { it != PhoneFeature.AUTOPLAY }
+
+ features.forEach {
+ every { fragment.getFeature() } returns it
+ every { fragment.initNormalFeature() } returns Unit
+ every { fragment.getSitePermission() } returns permissions
+
+ fragment.onCreateView(LayoutInflater.from(testContext), null, null)
+
+ verify {
+ fragment.initNormalFeature()
+ fragment.bindBlockedByAndroidContainer()
+ fragment.initClearPermissionsButton()
+ }
+ }
+ }
+
+ @Test
+ fun `WHEN initAutoplay is called THEN AllowAll, BlockAll and BlockAudible radio options will be configure`() {
+ every { fragment.initAutoplayOption(any(), any()) } returns Unit
+ every { fragment.getSitePermission() } returns permissions
+ every { settings.getSitePermissionsCustomSettingsRules() } returns getRules()
+
+ fragment.initAutoplay()
+
+ verify {
+ fragment.initAutoplayOption(R.id.ask_to_allow_radio, any<AutoplayValue.AllowAll>())
+ fragment.initAutoplayOption(R.id.block_radio, any<AutoplayValue.BlockAll>())
+ fragment.initAutoplayOption(R.id.optional_radio, any<AutoplayValue.BlockAudible>())
+ }
+ }
+
+ @Test
+ fun `WHEN initAutoplayOption is called THEN the radio button will visible and a click listener will be attached`() {
+ val radioButton = spyk(RadioButton(testContext))
+ val rootView = mockk<View>()
+ val autoplayValue = mockk<AutoplayValue>(relaxed = true)
+
+ radioButton.isVisible = false
+
+ fragment.rootView = rootView
+ every { rootView.findViewById<View>(any()) } returns radioButton
+ every { autoplayValue.label } returns "label"
+ with(fragment) {
+ every { updatedSitePermissions(any()) } returns Unit
+ every { any<RadioButton>().restoreState(any()) } returns Unit
+ }
+
+ fragment.initAutoplayOption(R.id.ask_to_allow_radio, autoplayValue)
+
+ assertTrue(radioButton.isVisible)
+ assertEquals(autoplayValue.label, radioButton.text)
+
+ with(fragment) {
+ verify {
+ any<RadioButton>().restoreState(autoplayValue)
+ }
+ }
+ }
+
+ @Test
+ fun `GIVEN a AllowAll value with autoplayAudible and autoplayInaudible rules are ALLOWED WHEN isSelected is called THEN isSelected will be true`() {
+ val rules = getRules().copy(
+ autoplayAudible = AutoplayAction.ALLOWED,
+ autoplayInaudible = AutoplayAction.ALLOWED,
+ )
+
+ val value = AutoplayValue.AllowAll(
+ label = "label",
+ rules = rules,
+ sitePermission = null,
+ )
+
+ assertTrue(value.isSelected())
+ }
+
+ @Test
+ fun `GIVEN a AllowAll value with autoplayAudible ALLOWED and autoplayInaudible BLOCKED rules WHEN isSelected is called THEN isSelected will be false`() {
+ val rules = getRules().copy(
+ autoplayInaudible = AutoplayAction.ALLOWED,
+ autoplayAudible = AutoplayAction.BLOCKED,
+ )
+
+ val value = AutoplayValue.AllowAll(
+ label = "label",
+ rules = rules,
+ sitePermission = null,
+ )
+
+ assertFalse(value.isSelected())
+ }
+
+ @Test
+ fun `GIVEN a AllowAll value with sitePermission autoplayAudible and autoplayInaudible are ALLOWED WHEN isSelected is called THEN isSelected will be true`() {
+ val rules = getRules().copy(
+ autoplayInaudible = AutoplayAction.ALLOWED,
+ autoplayAudible = AutoplayAction.BLOCKED,
+ )
+
+ val value = AutoplayValue.AllowAll(
+ label = "label",
+ rules = rules,
+ sitePermission = SitePermissions(
+ origin = "",
+ savedAt = 0L,
+ autoplayAudible = AutoplayStatus.ALLOWED,
+ autoplayInaudible = AutoplayStatus.ALLOWED,
+ ),
+ )
+
+ assertTrue(value.isSelected())
+ }
+
+ @Test
+ fun `GIVEN a AllowAll value with sitePermission autoplayAudible and autoplayInaudible are BLOCKED WHEN isSelected is called THEN isSelected will be false`() {
+ val rules = getRules().copy(
+ autoplayInaudible = AutoplayAction.ALLOWED,
+ autoplayAudible = AutoplayAction.BLOCKED,
+ )
+
+ val value = AutoplayValue.AllowAll(
+ label = "label",
+ rules = rules,
+ sitePermission = SitePermissions(
+ origin = "",
+ savedAt = 0L,
+ autoplayAudible = AutoplayStatus.BLOCKED,
+ autoplayInaudible = AutoplayStatus.BLOCKED,
+ ),
+ )
+
+ assertFalse(value.isSelected())
+ }
+
+ @Test
+ fun `GIVEN a BlockAll value with autoplayAudible and autoplayInaudible rules are BLOCKED WHEN isSelected is called THEN isSelected will be true`() {
+ val rules = getRules().copy(
+ autoplayAudible = AutoplayAction.BLOCKED,
+ autoplayInaudible = AutoplayAction.BLOCKED,
+ )
+
+ val value = AutoplayValue.BlockAll(
+ label = "label",
+ rules = rules,
+ sitePermission = null,
+ )
+
+ assertTrue(value.isSelected())
+ }
+
+ @Test
+ fun `GIVEN a BlockAll value with autoplayInaudible BLOCKED and autoplayAudible ALLOWED rules WHEN isSelected is called THEN isSelected will be false`() {
+ val rules = getRules().copy(
+ autoplayInaudible = AutoplayAction.BLOCKED,
+ autoplayAudible = AutoplayAction.ALLOWED,
+ )
+
+ val value = AutoplayValue.BlockAll(
+ label = "label",
+ rules = rules,
+ sitePermission = null,
+ )
+
+ assertFalse(value.isSelected())
+ }
+
+ @Test
+ fun `GIVEN a BlockAll value with sitePermission autoplayAudible and autoplayInaudible are BLOCKED WHEN isSelected THEN isSelected will be true`() {
+ val rules = getRules().copy(
+ autoplayInaudible = AutoplayAction.BLOCKED,
+ autoplayAudible = AutoplayAction.ALLOWED,
+ )
+
+ val value = AutoplayValue.BlockAll(
+ label = "label",
+ rules = rules,
+ sitePermission = SitePermissions(
+ origin = "",
+ savedAt = 0L,
+ autoplayAudible = AutoplayStatus.BLOCKED,
+ autoplayInaudible = AutoplayStatus.BLOCKED,
+ ),
+ )
+
+ assertTrue(value.isSelected())
+ }
+
+ @Test
+ fun `GIVEN a BlockAll value with sitePermission autoplayAudible ALLOWED and autoplayInaudible BLOCKED WHEN isSelected is called THEN isSelected will be false`() {
+ val rules = getRules().copy(
+ autoplayInaudible = AutoplayAction.BLOCKED,
+ autoplayAudible = AutoplayAction.ALLOWED,
+ )
+
+ val value = AutoplayValue.BlockAll(
+ label = "label",
+ rules = rules,
+ sitePermission = SitePermissions(
+ origin = "",
+ savedAt = 0L,
+ autoplayAudible = AutoplayStatus.ALLOWED,
+ autoplayInaudible = AutoplayStatus.BLOCKED,
+ ),
+ )
+
+ assertFalse(value.isSelected())
+ }
+
+ @Test
+ fun `GIVEN a BlockAudible value with autoplayAudible BLOCKED and autoplayInaudible ALLOWED rules WHEN isSelected is called THEN isSelected will be true`() {
+ val rules = getRules().copy(
+ autoplayAudible = AutoplayAction.BLOCKED,
+ autoplayInaudible = AutoplayAction.ALLOWED,
+ )
+
+ val value = AutoplayValue.BlockAudible(
+ label = "label",
+ rules = rules,
+ sitePermission = null,
+ )
+
+ assertTrue(value.isSelected())
+ }
+
+ @Test
+ fun `GIVEN a BlockAudible value with autoplayInaudible and autoplayAudible BLOCKED rules WHEN isSelected is called THEN isSelected will be false`() {
+ val rules = getRules().copy(
+ autoplayInaudible = AutoplayAction.BLOCKED,
+ autoplayAudible = AutoplayAction.BLOCKED,
+ )
+
+ val value = AutoplayValue.BlockAudible(
+ label = "label",
+ rules = rules,
+ sitePermission = null,
+ )
+
+ assertFalse(value.isSelected())
+ }
+
+ @Test
+ fun `GIVEN a BlockAudible with sitePermission autoplayAudible BLOCKED and autoplayInaudible ALLOWED WHEN isSelected is called THEN isSelected will be true`() {
+ val rules = getRules().copy(
+ autoplayInaudible = AutoplayAction.BLOCKED,
+ autoplayAudible = AutoplayAction.ALLOWED,
+ )
+
+ val value = AutoplayValue.BlockAudible(
+ label = "label",
+ rules = rules,
+ sitePermission = SitePermissions(
+ origin = "",
+ savedAt = 0L,
+ autoplayAudible = AutoplayStatus.BLOCKED,
+ autoplayInaudible = AutoplayStatus.ALLOWED,
+ ),
+ )
+
+ assertTrue(value.isSelected())
+ }
+
+ @Test
+ fun `GIVEN a BlockAudible with sitePermission autoplayAudible ALLOWED and autoplayInaudible BLOCKED WHEN isSelected is called THEN isSelected will be false`() {
+ val rules = getRules().copy(
+ autoplayInaudible = AutoplayAction.BLOCKED,
+ autoplayAudible = AutoplayAction.ALLOWED,
+ )
+
+ val value = AutoplayValue.BlockAudible(
+ label = "label",
+ rules = rules,
+ sitePermission = SitePermissions(
+ origin = "",
+ savedAt = 0L,
+ autoplayAudible = AutoplayStatus.ALLOWED,
+ autoplayInaudible = AutoplayStatus.BLOCKED,
+ ),
+ )
+
+ assertFalse(value.isSelected())
+ }
+
+ @Test
+ fun `GIVEN a AllowAll WHEN createSitePermissionsFromCustomRules is called THEN rules will included autoplayAudible and autoplayInaudible ALLOWED`() {
+ val rules = getRules().copy(
+ autoplayAudible = AutoplayAction.BLOCKED,
+ autoplayInaudible = AutoplayAction.BLOCKED,
+ )
+
+ every { settings.getSitePermissionsCustomSettingsRules() } returns rules
+
+ val value = AutoplayValue.AllowAll(
+ label = "label",
+ rules = rules,
+ sitePermission = null,
+ )
+
+ val result = value.createSitePermissionsFromCustomRules("mozilla.org", settings)
+
+ assertEquals(AutoplayStatus.ALLOWED, result.autoplayAudible)
+ assertEquals(AutoplayStatus.ALLOWED, result.autoplayInaudible)
+ assertEquals(rules.camera.toStatus(), result.camera)
+ assertEquals(rules.location.toStatus(), result.location)
+ assertEquals(rules.microphone.toStatus(), result.microphone)
+ assertEquals(rules.notification.toStatus(), result.notification)
+ assertEquals(rules.persistentStorage.toStatus(), result.localStorage)
+ assertEquals(rules.crossOriginStorageAccess.toStatus(), result.crossOriginStorageAccess)
+ assertEquals(rules.mediaKeySystemAccess.toStatus(), result.mediaKeySystemAccess)
+ }
+
+ @Test
+ fun `GIVEN a BlockAll WHEN createSitePermissionsFromCustomRules is called THEN rules will included autoplayAudible and autoplayInaudible BLOCKED`() {
+ val rules = getRules().copy(
+ autoplayAudible = AutoplayAction.ALLOWED,
+ autoplayInaudible = AutoplayAction.ALLOWED,
+ )
+
+ every { settings.getSitePermissionsCustomSettingsRules() } returns rules
+
+ val value = AutoplayValue.BlockAll(
+ label = "label",
+ rules = rules,
+ sitePermission = null,
+ )
+
+ val result = value.createSitePermissionsFromCustomRules("mozilla.org", settings)
+
+ assertEquals(AutoplayStatus.BLOCKED, result.autoplayAudible)
+ assertEquals(AutoplayStatus.BLOCKED, result.autoplayInaudible)
+ assertEquals(rules.camera.toStatus(), result.camera)
+ assertEquals(rules.location.toStatus(), result.location)
+ assertEquals(rules.microphone.toStatus(), result.microphone)
+ assertEquals(rules.notification.toStatus(), result.notification)
+ assertEquals(rules.persistentStorage.toStatus(), result.localStorage)
+ assertEquals(rules.crossOriginStorageAccess.toStatus(), result.crossOriginStorageAccess)
+ assertEquals(rules.mediaKeySystemAccess.toStatus(), result.mediaKeySystemAccess)
+ }
+
+ @Test
+ fun `GIVEN a BlockAudible WHEN createSitePermissionsFromCustomRules is called THEN rules will included autoplayAudible BLOCKED and autoplayInaudible ALLOWED`() {
+ val rules = getRules().copy(
+ autoplayAudible = AutoplayAction.ALLOWED,
+ autoplayInaudible = AutoplayAction.ALLOWED,
+ )
+
+ every { settings.getSitePermissionsCustomSettingsRules() } returns rules
+
+ val value = AutoplayValue.BlockAudible(
+ label = "label",
+ rules = rules,
+ sitePermission = null,
+ )
+
+ val result = value.createSitePermissionsFromCustomRules("mozilla.org", settings)
+
+ assertEquals(AutoplayStatus.BLOCKED, result.autoplayAudible)
+ assertEquals(AutoplayStatus.ALLOWED, result.autoplayInaudible)
+ assertEquals(rules.camera.toStatus(), result.camera)
+ assertEquals(rules.location.toStatus(), result.location)
+ assertEquals(rules.microphone.toStatus(), result.microphone)
+ assertEquals(rules.notification.toStatus(), result.notification)
+ assertEquals(rules.persistentStorage.toStatus(), result.localStorage)
+ assertEquals(rules.crossOriginStorageAccess.toStatus(), result.crossOriginStorageAccess)
+ assertEquals(rules.mediaKeySystemAccess.toStatus(), result.mediaKeySystemAccess)
+ }
+
+ @Test
+ fun `GIVEN a AllowAll WHEN updateSitePermissions is called THEN site permissions will include autoplayAudible and autoplayInaudible ALLOWED`() {
+ val sitePermissions = SitePermissions(
+ origin = "origin",
+ savedAt = 0L,
+ autoplayAudible = AutoplayStatus.BLOCKED,
+ autoplayInaudible = AutoplayStatus.BLOCKED,
+ )
+
+ val value = AutoplayValue.AllowAll(
+ label = "label",
+ rules = mockk(),
+ sitePermission = null,
+ )
+
+ val result = value.updateSitePermissions(sitePermissions)
+
+ assertEquals(AutoplayStatus.ALLOWED, result.autoplayAudible)
+ assertEquals(AutoplayStatus.ALLOWED, result.autoplayInaudible)
+ assertEquals(sitePermissions.camera, result.camera)
+ assertEquals(sitePermissions.location, result.location)
+ assertEquals(sitePermissions.microphone, result.microphone)
+ assertEquals(sitePermissions.notification, result.notification)
+ assertEquals(sitePermissions.localStorage, result.localStorage)
+ assertEquals(sitePermissions.crossOriginStorageAccess, result.crossOriginStorageAccess)
+ assertEquals(sitePermissions.mediaKeySystemAccess, result.mediaKeySystemAccess)
+ }
+
+ @Test
+ fun `GIVEN a BlockAll WHEN updateSitePermissions is called THEN site permissions will include autoplayAudible and autoplayInaudible BLOCKED`() {
+ val sitePermissions = SitePermissions(
+ origin = "origin",
+ savedAt = 0L,
+ autoplayAudible = AutoplayStatus.ALLOWED,
+ autoplayInaudible = AutoplayStatus.ALLOWED,
+ )
+
+ val value = AutoplayValue.BlockAll(
+ label = "label",
+ rules = mockk(),
+ sitePermission = null,
+ )
+
+ val result = value.updateSitePermissions(sitePermissions)
+
+ assertEquals(AutoplayStatus.BLOCKED, result.autoplayAudible)
+ assertEquals(AutoplayStatus.BLOCKED, result.autoplayInaudible)
+ assertEquals(sitePermissions.camera, result.camera)
+ assertEquals(sitePermissions.location, result.location)
+ assertEquals(sitePermissions.microphone, result.microphone)
+ assertEquals(sitePermissions.notification, result.notification)
+ assertEquals(sitePermissions.localStorage, result.localStorage)
+ assertEquals(sitePermissions.crossOriginStorageAccess, result.crossOriginStorageAccess)
+ assertEquals(sitePermissions.mediaKeySystemAccess, result.mediaKeySystemAccess)
+ }
+
+ @Test
+ fun `GIVEN a BlockAudible WHEN updateSitePermissions is called THEN site permissions will include autoplayAudible BLOCKED and autoplayInaudible ALLOWED`() {
+ val sitePermissions = SitePermissions(
+ origin = "origin",
+ savedAt = 0L,
+ autoplayAudible = AutoplayStatus.ALLOWED,
+ autoplayInaudible = AutoplayStatus.BLOCKED,
+ )
+
+ val value = AutoplayValue.BlockAudible(
+ label = "label",
+ rules = mockk(),
+ sitePermission = null,
+ )
+
+ val result = value.updateSitePermissions(sitePermissions)
+
+ assertEquals(AutoplayStatus.BLOCKED, result.autoplayAudible)
+ assertEquals(AutoplayStatus.ALLOWED, result.autoplayInaudible)
+ assertEquals(sitePermissions.camera, result.camera)
+ assertEquals(sitePermissions.location, result.location)
+ assertEquals(sitePermissions.microphone, result.microphone)
+ assertEquals(sitePermissions.notification, result.notification)
+ assertEquals(sitePermissions.localStorage, result.localStorage)
+ assertEquals(sitePermissions.crossOriginStorageAccess, result.crossOriginStorageAccess)
+ assertEquals(sitePermissions.mediaKeySystemAccess, result.mediaKeySystemAccess)
+ }
+
+ @Test
+ fun `WHEN calling AutoplayValue values THEN values for AllowAll,BlockAll and BlockAudible will be returned`() {
+ val values = AutoplayValue.values(testContext, settings, null)
+
+ assertTrue(values.any { it is AutoplayValue.AllowAll })
+ assertTrue(values.any { it is AutoplayValue.BlockAll })
+ assertTrue(values.any { it is AutoplayValue.BlockAudible })
+ assertEquals(3, values.size)
+ }
+
+ private fun getRules() = SitePermissionsRules(
+ camera = Action.ASK_TO_ALLOW,
+ location = Action.ASK_TO_ALLOW,
+ microphone = Action.ASK_TO_ALLOW,
+ notification = Action.ASK_TO_ALLOW,
+ autoplayAudible = AutoplayAction.BLOCKED,
+ autoplayInaudible = AutoplayAction.BLOCKED,
+ persistentStorage = Action.ASK_TO_ALLOW,
+ mediaKeySystemAccess = Action.ASK_TO_ALLOW,
+ crossOriginStorageAccess = Action.ASK_TO_ALLOW,
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/sitepermissions/SitePermissionsWifiIntegrationTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/sitepermissions/SitePermissionsWifiIntegrationTest.kt
new file mode 100644
index 0000000000..976b6caff2
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/sitepermissions/SitePermissionsWifiIntegrationTest.kt
@@ -0,0 +1,129 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.sitepermissions
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.feature.sitepermissions.SitePermissionsRules.Action.ALLOWED
+import mozilla.components.feature.sitepermissions.SitePermissionsRules.Action.BLOCKED
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.settings.PhoneFeature.AUTOPLAY_AUDIBLE
+import org.mozilla.fenix.settings.PhoneFeature.AUTOPLAY_INAUDIBLE
+import org.mozilla.fenix.utils.Settings
+import org.mozilla.fenix.wifi.SitePermissionsWifiIntegration
+import org.mozilla.fenix.wifi.WifiConnectionMonitor
+
+class SitePermissionsWifiIntegrationTest {
+ lateinit var settings: Settings
+ lateinit var wifiIntegration: SitePermissionsWifiIntegration
+ lateinit var wifiConnectionMonitor: WifiConnectionMonitor
+
+ @Before
+ fun setUp() {
+ settings = mockk(relaxed = true)
+ wifiConnectionMonitor = mockk(relaxed = true)
+ wifiIntegration = SitePermissionsWifiIntegration(settings, wifiConnectionMonitor)
+ }
+
+ @Test
+ fun `GIVEN auto play is set to be on allow only on wifi WHEN the feature starts THEN listen for wifi changes`() {
+ every { settings.getAutoplayUserSetting() } returns AUTOPLAY_ALLOW_ON_WIFI
+
+ wifiIntegration.start()
+
+ verify(exactly = 1) {
+ wifiConnectionMonitor.start()
+ }
+ verify(exactly = 1) {
+ wifiConnectionMonitor.addOnWifiConnectedChangedListener(wifiIntegration.wifiConnectedListener)
+ }
+ }
+
+ @Test
+ fun `GIVEN auto play is not set to allow only on wifi WHEN the feature starts THEN will not listen for wifi changes`() {
+ val autoPlaySettings =
+ listOf(AUTOPLAY_BLOCK_ALL, AUTOPLAY_BLOCK_AUDIBLE, AUTOPLAY_ALLOW_ALL)
+
+ autoPlaySettings.forEach { autoPlaySetting ->
+ every { settings.getAutoplayUserSetting() } returns autoPlaySetting
+
+ wifiIntegration.start()
+
+ verify(exactly = 0) {
+ wifiConnectionMonitor.addOnWifiConnectedChangedListener(wifiIntegration.wifiConnectedListener)
+ }
+ verify(exactly = 0) {
+ wifiConnectionMonitor.addOnWifiConnectedChangedListener(wifiIntegration.wifiConnectedListener)
+ }
+ }
+ }
+
+ @Test
+ fun `WHEN stopping the feature THEN all listeners will be removed`() {
+ wifiIntegration.stop()
+
+ verify(exactly = 1) {
+ wifiConnectionMonitor.stop()
+ }
+ verify(exactly = 1) {
+ wifiConnectionMonitor.removeOnWifiConnectedChangedListener(wifiIntegration.wifiConnectedListener)
+ }
+ }
+
+ @Test
+ fun `GIVEN wifi is connected and autoplay is set to allow only on wifi WHEN wifi changes to connected THEN the autoplay setting must be allowed`() {
+ every { settings.getAutoplayUserSetting() } returns AUTOPLAY_ALLOW_ON_WIFI
+
+ wifiIntegration.wifiConnectedListener(true)
+
+ verify(exactly = 1) {
+ settings.setSitePermissionsPhoneFeatureAction(AUTOPLAY_AUDIBLE, ALLOWED)
+ }
+ verify(exactly = 1) {
+ settings.setSitePermissionsPhoneFeatureAction(AUTOPLAY_INAUDIBLE, ALLOWED)
+ }
+ }
+
+ @Test
+ fun `GIVEN wifi is connected and autoplay is set to allow only on wifi WHEN wifi changes to not connected THEN the autoplay setting must be blocked`() {
+ every { settings.getAutoplayUserSetting() } returns AUTOPLAY_ALLOW_ON_WIFI
+
+ wifiIntegration.wifiConnectedListener(false)
+
+ verify(exactly = 1) {
+ settings.setSitePermissionsPhoneFeatureAction(AUTOPLAY_AUDIBLE, BLOCKED)
+ }
+
+ verify(exactly = 1) {
+ settings.setSitePermissionsPhoneFeatureAction(AUTOPLAY_INAUDIBLE, BLOCKED)
+ }
+ }
+
+ @Test
+ fun `GIVEN wifi is connected and autoplay is different from allow on wifi WHEN wifi changes THEN all the wifi listener will be stopped`() {
+ val autoPlaySettings = listOf(AUTOPLAY_BLOCK_ALL, AUTOPLAY_BLOCK_AUDIBLE, AUTOPLAY_ALLOW_ALL)
+
+ autoPlaySettings.forEach { autoPlaySetting ->
+ every { settings.getAutoplayUserSetting() } returns autoPlaySetting
+
+ wifiIntegration.wifiConnectedListener(true)
+ wifiIntegration.wifiConnectedListener(false)
+
+ verify(atLeast = 1) {
+ wifiConnectionMonitor.stop()
+ wifiConnectionMonitor.removeOnWifiConnectedChangedListener(wifiIntegration.wifiConnectedListener)
+ }
+ verify(atLeast = 1) {
+ wifiConnectionMonitor.removeOnWifiConnectedChangedListener(wifiIntegration.wifiConnectedListener)
+ }
+
+ verify(exactly = 0) {
+ settings.setSitePermissionsPhoneFeatureAction(any(), any())
+ }
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/studies/DefaultStudiesInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/studies/DefaultStudiesInteractorTest.kt
new file mode 100644
index 0000000000..8ea3b06c85
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/studies/DefaultStudiesInteractorTest.kt
@@ -0,0 +1,60 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.studies
+
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.RelaxedMockK
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.runs
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.service.nimbus.NimbusApi
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.experiments.nimbus.internal.EnrolledExperiment
+import org.mozilla.fenix.BrowserDirection
+import org.mozilla.fenix.HomeActivity
+
+class DefaultStudiesInteractorTest {
+ @RelaxedMockK
+ private lateinit var activity: HomeActivity
+
+ @RelaxedMockK
+ private lateinit var experiments: NimbusApi
+
+ private lateinit var interactor: DefaultStudiesInteractor
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ interactor = spyk(DefaultStudiesInteractor(activity, experiments))
+ }
+
+ @Test
+ fun `WHEN calling openWebsite THEN delegate to the homeActivity`() {
+ val url = ""
+ interactor.openWebsite(url)
+
+ verify {
+ activity.openToBrowserAndLoad(url, true, BrowserDirection.FromStudiesFragment)
+ }
+ }
+
+ @Test
+ fun `WHEN calling removeStudy THEN delegate to the NimbusApi`() {
+ val experiment = mockk<EnrolledExperiment>(relaxed = true)
+
+ every { experiment.slug } returns "slug"
+ every { interactor.killApplication() } just runs
+
+ interactor.removeStudy(experiment)
+
+ verify {
+ experiments.optOut("slug")
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/studies/StudiesAdapterTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/studies/StudiesAdapterTest.kt
new file mode 100644
index 0000000000..610e3144c0
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/studies/StudiesAdapterTest.kt
@@ -0,0 +1,155 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.studies
+
+import android.view.View
+import android.widget.TextView
+import androidx.core.view.isVisible
+import com.google.android.material.button.MaterialButton
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.RelaxedMockK
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.runs
+import io.mockk.spyk
+import io.mockk.verify
+import junit.framework.TestCase.assertEquals
+import junit.framework.TestCase.assertFalse
+import junit.framework.TestCase.assertTrue
+import mozilla.components.service.nimbus.messaging.MESSAGING_FEATURE_ID
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.experiments.nimbus.internal.EnrolledExperiment
+import org.mozilla.fenix.R
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.studies.CustomViewHolder.SectionViewHolder
+import org.mozilla.fenix.settings.studies.CustomViewHolder.StudyViewHolder
+import org.mozilla.fenix.settings.studies.StudiesAdapter.Section
+
+@RunWith(FenixRobolectricTestRunner::class)
+class StudiesAdapterTest {
+ @RelaxedMockK
+ private lateinit var delegate: StudiesAdapterDelegate
+
+ private lateinit var adapter: StudiesAdapter
+ private lateinit var studies: List<EnrolledExperiment>
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ studies = emptyList()
+ adapter = spyk(StudiesAdapter(delegate, studies, false))
+ }
+
+ @Test
+ fun `WHEN bindSection THEN bind the section information`() {
+ val holder = mockk<SectionViewHolder>()
+ val section = Section(R.string.studies_active, true)
+ val titleView = mockk<TextView>(relaxed = true)
+ val divider = mockk<View>(relaxed = true)
+
+ every { holder.titleView } returns titleView
+ every { holder.divider } returns divider
+
+ adapter.bindSection(holder, section)
+
+ verify {
+ titleView.setText(section.title)
+ divider.isVisible = section.visibleDivider
+ }
+ }
+
+ @Test
+ fun `WHEN bindStudy THEN bind the study information`() {
+ val holder = mockk<StudyViewHolder>()
+ val study = mockk<EnrolledExperiment>()
+ val titleView = spyk(TextView(testContext))
+ val summaryView = mockk<TextView>(relaxed = true)
+ val deleteButton = spyk(MaterialButton(testContext))
+
+ every { study.slug } returns "slug"
+ every { study.userFacingName } returns "userFacingName"
+ every { study.userFacingDescription } returns "userFacingDescription"
+ every { holder.titleView } returns titleView
+ every { holder.summaryView } returns summaryView
+ every { holder.deleteButton } returns deleteButton
+
+ adapter = spyk(StudiesAdapter(delegate, listOf(study), false))
+
+ every { adapter.showDeleteDialog(any(), any()) } returns mockk()
+
+ adapter.bindStudy(holder, study)
+
+ verify {
+ titleView.text = any()
+ summaryView.text = any()
+ }
+
+ deleteButton.performClick()
+
+ verify {
+ adapter.showDeleteDialog(any(), any())
+ }
+ }
+
+ @Test
+ fun `WHEN removeStudy THEN the study should be removed`() {
+ val study = mockk<EnrolledExperiment>()
+
+ every { study.slug } returns "slug"
+
+ adapter = spyk(StudiesAdapter(delegate, listOf(study), false))
+
+ every { adapter.submitList(any()) } just runs
+
+ assertFalse(adapter.studiesMap.isEmpty())
+
+ adapter.removeStudy(study)
+
+ assertTrue(adapter.studiesMap.isEmpty())
+
+ verify {
+ adapter.submitList(any())
+ }
+ }
+
+ @Test
+ fun `WHEN calling createListWithSections THEN returns the section + experiments`() {
+ val study = mockk<EnrolledExperiment>()
+
+ every { study.slug } returns "slug"
+ every { study.featureIds } returns listOf()
+
+ adapter = spyk(StudiesAdapter(delegate, listOf(study), false))
+
+ val list = adapter.createListWithSections(listOf(study))
+
+ assertEquals(2, list.size)
+ assertTrue(list[0] is Section)
+ assertTrue(list[1] is EnrolledExperiment)
+ }
+
+ @Test
+ fun `WHEN calling createListWithSections THEN returns the section + experiments, filtering messages`() {
+ val study = mockk<EnrolledExperiment>()
+ every { study.slug } returns "slug"
+ every { study.featureIds } returns listOf("dummy")
+
+ val message = mockk<EnrolledExperiment>()
+ every { message.slug } returns "aMessage"
+ every { message.featureIds } returns listOf(MESSAGING_FEATURE_ID)
+
+ adapter = spyk(StudiesAdapter(delegate, listOf(study, message), false))
+
+ val list = adapter.createListWithSections(listOf(study))
+
+ assertEquals(2, list.size)
+ assertTrue(list[0] is Section)
+ assertTrue(list[1] is EnrolledExperiment)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/studies/StudiesViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/studies/StudiesViewTest.kt
new file mode 100644
index 0000000000..0c9bf0541e
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/studies/StudiesViewTest.kt
@@ -0,0 +1,105 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.studies
+
+import android.widget.TextView
+import androidx.appcompat.widget.SwitchCompat
+import androidx.recyclerview.widget.RecyclerView
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.RelaxedMockK
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.runs
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.service.nimbus.NimbusApi
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.experiments.nimbus.internal.EnrolledExperiment
+import org.mozilla.fenix.databinding.SettingsStudiesBinding
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class StudiesViewTest {
+
+ @RelaxedMockK
+ private lateinit var experiments: NimbusApi
+
+ @RelaxedMockK
+ private lateinit var binding: SettingsStudiesBinding
+
+ @RelaxedMockK
+ private lateinit var interactor: StudiesInteractor
+
+ @RelaxedMockK
+ private lateinit var settings: Settings
+
+ private lateinit var view: StudiesView
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val testCoroutineScope = coroutinesTestRule.scope
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ view = spyk(
+ StudiesView(
+ testCoroutineScope,
+ testContext,
+ binding,
+ interactor,
+ settings,
+ experiments,
+ isAttached = { true },
+ ),
+ )
+ }
+
+ @Test
+ fun `WHEN calling bind THEN bind all the related information`() = runTestOnMain {
+ val studiesTitle = mockk<TextView>(relaxed = true)
+ val studiesSwitch = mockk<SwitchCompat>(relaxed = true)
+ val studiesList = mockk<RecyclerView>(relaxed = true)
+
+ every { settings.isExperimentationEnabled } returns true
+ every { view.provideStudiesTitle() } returns studiesTitle
+ every { view.provideStudiesSwitch() } returns studiesSwitch
+ every { view.provideStudiesList() } returns studiesList
+ every { view.bindDescription() } just runs
+ every { view.getSwitchTitle() } returns "Title"
+
+ view.bind()
+
+ verify {
+ studiesTitle.text = "Title"
+ studiesSwitch.isChecked = true
+ view.bindDescription()
+ studiesList.adapter = any()
+ }
+ }
+
+ @Test
+ fun `WHEN calling onRemoveButtonClicked THEN delegate to the interactor`() = runTestOnMain {
+ val experiment = mockk<EnrolledExperiment>()
+ val adapter = mockk<StudiesAdapter>(relaxed = true)
+
+ every { view.adapter } returns adapter
+
+ view.onRemoveButtonClicked(experiment)
+
+ verify {
+ interactor.removeStudy(experiment)
+ adapter.removeStudy(experiment)
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/wallpaper/ExtensionsTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/wallpaper/ExtensionsTest.kt
new file mode 100644
index 0000000000..9ce65835bc
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/wallpaper/ExtensionsTest.kt
@@ -0,0 +1,205 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.settings.wallpaper
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.mozilla.fenix.wallpapers.Wallpaper
+
+class ExtensionsTest {
+ private val classicCollection = getSeasonalCollection("classic-firefox")
+
+ @Test
+ fun `GIVEN wallpapers that include the default WHEN grouped by collection THEN default will be added to classic firefox`() {
+ val seasonalCollection = getSeasonalCollection("finally fall")
+ val classicFirefoxWallpapers = (0..5).map { generateClassicFirefoxWallpaper("firefox$it") }
+ val seasonalWallpapers = (0..5).map { generateSeasonalWallpaperCollection("${seasonalCollection.name}$it", seasonalCollection.name) }
+ val allWallpapers = listOf(Wallpaper.Default) + classicFirefoxWallpapers + seasonalWallpapers
+
+ val result = allWallpapers.groupByDisplayableCollection()
+
+ assertEquals(2, result.size)
+ assertEquals(listOf(Wallpaper.Default) + classicFirefoxWallpapers, result[classicCollection])
+ assertEquals(seasonalWallpapers, result[seasonalCollection])
+ }
+
+ @Test
+ fun `GIVEN no wallpapers but the default WHEN grouped by collection THEN the default will still be present`() {
+ val result = listOf(Wallpaper.Default).groupByDisplayableCollection()
+
+ assertEquals(1, result.size)
+ assertEquals(listOf(Wallpaper.Default), result[Wallpaper.ClassicFirefoxCollection])
+ }
+
+ @Test
+ fun `GIVEN wallpapers with thumbnails that have not downloaded WHEN grouped by collection THEN wallpapers without thumbnails will not be included`() {
+ val seasonalCollection = getSeasonalCollection("finally fall")
+ val classicFirefoxWallpapers = (0..5).map { generateClassicFirefoxWallpaper("firefox$it") }
+ val downloadedSeasonalWallpapers = (0..5).map { generateSeasonalWallpaperCollection("${seasonalCollection.name}$it", seasonalCollection.name) }
+ val nonDownloadedSeasonalWallpapers = (0..5).map {
+ generateSeasonalWallpaperCollection(
+ "${seasonalCollection.name}$it",
+ seasonalCollection.name,
+ Wallpaper.ImageFileState.Error,
+ )
+ }
+ val allWallpapers = listOf(Wallpaper.Default) + classicFirefoxWallpapers + downloadedSeasonalWallpapers + nonDownloadedSeasonalWallpapers
+
+ val result = allWallpapers.groupByDisplayableCollection()
+
+ assertEquals(2, result.size)
+ assertEquals(listOf(Wallpaper.Default) + classicFirefoxWallpapers, result[classicCollection])
+ assertEquals(downloadedSeasonalWallpapers, result[seasonalCollection])
+ }
+
+ @Test
+ fun `GIVEN that classic firefox thumbnails fail to download WHEN grouped by collection THEN default is still available`() {
+ val seasonalCollection = getSeasonalCollection("finally fall")
+ val downloadedSeasonalWallpapers = (0..5).map {
+ generateSeasonalWallpaperCollection(
+ "${seasonalCollection.name}$it",
+ seasonalCollection.name,
+ )
+ }
+ val allWallpapers = listOf(Wallpaper.Default) + downloadedSeasonalWallpapers
+
+ val result = allWallpapers.groupByDisplayableCollection()
+
+ assertEquals(2, result.size)
+ assertEquals(listOf(Wallpaper.Default), result[classicCollection])
+ assertEquals(downloadedSeasonalWallpapers, result[seasonalCollection])
+ }
+
+ @Test
+ fun `GIVEN two collections of appropriate size WHEN fetched for onboarding THEN result contains 3 seasonal and 2 classic`() {
+ val seasonalCollection = getSeasonalCollection("finally fall")
+ val seasonalWallpapers = (0..5).map { generateSeasonalWallpaperCollection("${seasonalCollection.name}$it", seasonalCollection.name) }
+ val classicFirefoxWallpapers = (0..5).map { generateClassicFirefoxWallpaper("firefox$it") }
+ val allWallpapers = listOf(Wallpaper.Default) + classicFirefoxWallpapers + seasonalWallpapers
+
+ val result = allWallpapers.getWallpapersForOnboarding()
+
+ assertEquals(3, result.count { it.collection.name == "finally fall" })
+ assertEquals(2, result.count { it.collection.name == classicCollection.name })
+ assertTrue(result.contains(Wallpaper.Default))
+ }
+
+ @Test
+ fun `GIVEN five collections of insufficient size WHEN fetched for onboarding THEN result contains 3 seasonal and 2 classic`() {
+ val seasonalCollectionA = getSeasonalCollection("finally winter")
+ val seasonalWallpapers = generateSeasonalWallpaperCollection("${seasonalCollectionA.name}$0", seasonalCollectionA.name)
+ val seasonalCollectionB = getSeasonalCollection("finally spring")
+ val seasonalWallpaperB = generateSeasonalWallpaperCollection("${seasonalCollectionB.name}$0", seasonalCollectionB.name)
+ val seasonalCollectionC = getSeasonalCollection("finally summer")
+ val seasonalWallpapersC = generateSeasonalWallpaperCollection("${seasonalCollectionC.name}$0", seasonalCollectionC.name)
+ val seasonalCollectionD = getSeasonalCollection("finally autumn")
+ val seasonalWallpaperD = generateSeasonalWallpaperCollection("${seasonalCollectionD.name}$0", seasonalCollectionD.name)
+ val seasonalCollectionE = getSeasonalCollection("finally vacation")
+ val seasonalWallpapersE = generateSeasonalWallpaperCollection("${seasonalCollectionE.name}$0", seasonalCollectionE.name)
+
+ val classicFirefoxWallpapers = (0..5).map { generateClassicFirefoxWallpaper("firefox$it") }
+ val allWallpapers = listOf(Wallpaper.Default) + classicFirefoxWallpapers + seasonalWallpapers +
+ seasonalWallpaperB + seasonalWallpapersC + seasonalWallpaperD + seasonalWallpapersE
+
+ val result = allWallpapers.getWallpapersForOnboarding()
+
+ assertEquals(3, result.count { it.collection.name != classicCollection.name && it != Wallpaper.Default })
+ assertEquals(2, result.count { it.collection.name == classicCollection.name })
+ assertTrue(result.contains(Wallpaper.Default))
+ }
+
+ @Test
+ fun `GIVEN seasonal collection of insufficient size WHEN grouped for onboarding THEN result contains all seasonal and the rest is classic`() {
+ val seasonalCollection = getSeasonalCollection("finally fall")
+ val seasonalWallpapers = generateSeasonalWallpaperCollection("${seasonalCollection.name}$0", seasonalCollection.name)
+ val classicFirefoxWallpapers = (0..5).map { generateClassicFirefoxWallpaper("firefox$it") }
+ val allWallpapers = listOf(Wallpaper.Default) + classicFirefoxWallpapers + seasonalWallpapers
+
+ val result = allWallpapers.getWallpapersForOnboarding()
+
+ assertEquals(1, result.count { it.collection.name == "finally fall" })
+ assertEquals(4, result.count { it.collection.name == classicCollection.name })
+ assertTrue(result.contains(Wallpaper.Default))
+ }
+
+ @Test
+ fun `GIVEN no seasonal collection WHEN grouped for onboarding THEN result contains all classic`() {
+ val classicFirefoxWallpapers = (0..5).map { generateClassicFirefoxWallpaper("firefox$it") }
+ val allWallpapers = listOf(Wallpaper.Default) + classicFirefoxWallpapers
+
+ val result = allWallpapers.getWallpapersForOnboarding()
+
+ assertEquals(5, result.count { it.collection.name == classicCollection.name })
+ assertTrue(result.contains(Wallpaper.Default))
+ }
+
+ @Test
+ fun `GIVEN insufficient items in classic collection WHEN grouped for onboarding THEN result contains all classic`() {
+ val classicFirefoxWallpapers = (0..2).map { generateClassicFirefoxWallpaper("firefox$it") }
+ val allWallpapers = listOf(Wallpaper.Default) + classicFirefoxWallpapers
+
+ val result = allWallpapers.getWallpapersForOnboarding()
+
+ assertEquals(3, result.count { it.collection.name == classicCollection.name })
+ assertTrue(result.contains(Wallpaper.Default))
+ }
+
+ @Test
+ fun `GIVEN no items in classic collection and some seasonal WHEN grouped for onboarding THEN result contains all seasonal`() {
+ val seasonalCollection = getSeasonalCollection("finally fall")
+ val seasonalWallpapers = (0..5).map { generateSeasonalWallpaperCollection("${seasonalCollection.name}$it", seasonalCollection.name) }
+ val allWallpapers = listOf(Wallpaper.Default) + seasonalWallpapers
+
+ val result = allWallpapers.getWallpapersForOnboarding()
+
+ assertEquals(5, result.count { it.collection.name == "finally fall" })
+ assertTrue(result.contains(Wallpaper.Default))
+ }
+
+ @Test
+ fun `GIVEN no items WHEN grouped for onboarding THEN result contains the default option`() {
+ val allWallpapers = listOf(Wallpaper.Default)
+
+ val result = allWallpapers.getWallpapersForOnboarding()
+
+ assertEquals(1, result.size)
+ assertTrue(result.contains(Wallpaper.Default))
+ }
+
+ private fun generateClassicFirefoxWallpaper(name: String) = Wallpaper(
+ name = name,
+ textColor = 0L,
+ cardColorLight = 0L,
+ cardColorDark = 0L,
+ thumbnailFileState = Wallpaper.ImageFileState.Downloaded,
+ assetsFileState = Wallpaper.ImageFileState.Downloaded,
+ collection = classicCollection,
+ )
+
+ private fun getSeasonalCollection(name: String) = Wallpaper.Collection(
+ name = name,
+ heading = null,
+ description = null,
+ learnMoreUrl = null,
+ availableLocales = null,
+ startDate = null,
+ endDate = null,
+ )
+
+ private fun generateSeasonalWallpaperCollection(
+ wallpaperName: String,
+ collectionName: String,
+ thumbnailState: Wallpaper.ImageFileState = Wallpaper.ImageFileState.Downloaded,
+ ) = Wallpaper(
+ name = wallpaperName,
+ textColor = 0L,
+ cardColorLight = 0L,
+ cardColorDark = 0L,
+ thumbnailFileState = thumbnailState,
+ assetsFileState = Wallpaper.ImageFileState.Downloaded,
+ collection = getSeasonalCollection(collectionName),
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/SaveToPDFMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/SaveToPDFMiddlewareTest.kt
new file mode 100644
index 0000000000..72db3c97cb
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/SaveToPDFMiddlewareTest.kt
@@ -0,0 +1,425 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.share
+
+import android.content.Context
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.browser.state.action.EngineAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.Events
+import org.mozilla.fenix.R
+import org.mozilla.fenix.browser.StandardSnackbarError
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppAction
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.geckoview.GeckoSession
+import java.io.IOException
+
+@RunWith(FenixRobolectricTestRunner::class)
+class SaveToPDFMiddlewareTest {
+ private lateinit var appStore: AppStore
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @get:Rule
+ val mainCoroutineTestRule = MainCoroutineRule()
+
+ // Only ERROR_PRINT_SETTINGS_SERVICE_NOT_AVAILABLE is available for testing
+ class MockGeckoPrintException() : GeckoSession.GeckoPrintException()
+
+ @Before
+ fun setup() {
+ appStore = mockk(relaxed = true)
+ every { testContext.components.appStore } returns appStore
+ }
+
+ @Test
+ fun `GIVEN a save to pdf request WHEN it fails unexpectedly THEN unknown failure telemetry is sent AND a snackbar error is shown`() =
+ runTestOnMain {
+ val exceptionToThrow = RuntimeException("reader save to pdf failed")
+ val middleware = SaveToPDFMiddleware(testContext)
+ val mockEngineSession: EngineSession = mockk<EngineSession>().apply {
+ every {
+ checkForPdfViewer(any(), any())
+ } answers {
+ secondArg<(Throwable) -> Unit>().invoke(exceptionToThrow)
+ }
+ }
+ val browserStore = BrowserStore(
+ middleware = listOf(middleware),
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab(
+ url = "https://mozilla.org",
+ id = "14",
+ engineSession = mockEngineSession,
+ ),
+ ),
+ ),
+ )
+ browserStore.dispatch(
+ EngineAction.SaveToPdfExceptionAction("14", exceptionToThrow),
+ )
+ browserStore.waitUntilIdle()
+ testScheduler.advanceUntilIdle()
+ val response = Events.saveToPdfFailure.testGetValue()?.firstOrNull()
+ assertNotNull(response)
+ val reason = response?.extra?.get("reason")
+ assertEquals("unknown", reason)
+ val source = response?.extra?.get("source")
+ assertEquals("unknown", source)
+ verify {
+ appStore.dispatch(
+ AppAction.UpdateStandardSnackbarErrorAction(
+ StandardSnackbarError(
+ testContext.getString(R.string.unable_to_save_to_pdf_error),
+ ),
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN a save to pdf request WHEN it fails due to io THEN io failure telemetry is sent AND a snackbar error is shown`() =
+ runTestOnMain {
+ val exceptionToThrow = IOException()
+ val middleware = SaveToPDFMiddleware(testContext)
+ val mockEngineSession: EngineSession = mockk<EngineSession>().apply {
+ every {
+ checkForPdfViewer(any(), any())
+ } answers {
+ secondArg<(Throwable) -> Unit>().invoke(exceptionToThrow)
+ }
+ }
+ val browserStore = BrowserStore(
+ middleware = listOf(middleware),
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab(
+ url = "https://mozilla.org",
+ id = "14",
+ engineSession = mockEngineSession,
+ ),
+ ),
+ ),
+ )
+ browserStore.dispatch(EngineAction.SaveToPdfExceptionAction("14", exceptionToThrow))
+ browserStore.waitUntilIdle()
+ testScheduler.advanceUntilIdle()
+ val response = Events.saveToPdfFailure.testGetValue()?.firstOrNull()
+ assertNotNull(response)
+ val reason = response?.extra?.get("reason")
+ assertEquals("io_error", reason)
+ val source = response?.extra?.get("source")
+ assertEquals("unknown", source)
+ verify {
+ appStore.dispatch(
+ AppAction.UpdateStandardSnackbarErrorAction(
+ StandardSnackbarError(
+ testContext.getString(R.string.unable_to_save_to_pdf_error),
+ ),
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN a save to pdf request WHEN it fails due to print exception THEN print exception failure telemetry is sent AND a snackbar error is shown`() =
+ runTestOnMain {
+ val exceptionToThrow = MockGeckoPrintException()
+ val middleware = SaveToPDFMiddleware(testContext)
+ val mockEngineSession: EngineSession = mockk<EngineSession>().apply {
+ every {
+ checkForPdfViewer(any(), any())
+ } answers {
+ secondArg<(Throwable) -> Unit>().invoke(exceptionToThrow)
+ }
+ }
+ val browserStore = BrowserStore(
+ middleware = listOf(middleware),
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab(
+ url = "https://mozilla.org",
+ id = "14",
+ engineSession = mockEngineSession,
+ ),
+ ),
+ ),
+ )
+ browserStore.dispatch(EngineAction.SaveToPdfExceptionAction("14", exceptionToThrow))
+ browserStore.waitUntilIdle()
+ testScheduler.advanceUntilIdle()
+ val response = Events.saveToPdfFailure.testGetValue()?.firstOrNull()
+ assertNotNull(response)
+ val reason = response?.extra?.get("reason")
+ assertEquals("no_settings_service", reason)
+ val source = response?.extra?.get("source")
+ assertEquals("unknown", source)
+ verify {
+ appStore.dispatch(
+ AppAction.UpdateStandardSnackbarErrorAction(
+ StandardSnackbarError(
+ testContext.getString(R.string.unable_to_save_to_pdf_error),
+ ),
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN a save to pdf request WHEN it completes THEN completed telemetry is sent`() =
+ runTestOnMain {
+ val middleware = SaveToPDFMiddleware(testContext)
+ val mockEngineSession: EngineSession = mockk<EngineSession>().apply {
+ every {
+ checkForPdfViewer(any(), any())
+ } answers {
+ firstArg<(Boolean) -> Unit>().invoke(false)
+ }
+ }
+ val browserStore = BrowserStore(
+ middleware = listOf(middleware),
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab(
+ url = "https://mozilla.org",
+ id = "14",
+ engineSession = mockEngineSession,
+ ),
+ ),
+ ),
+ )
+ browserStore.dispatch(EngineAction.SaveToPdfCompleteAction("14"))
+ browserStore.waitUntilIdle()
+ testScheduler.advanceUntilIdle()
+ val response = Events.saveToPdfCompleted.testGetValue()
+ assertNotNull(response)
+ val source = response?.firstOrNull()?.extra?.get("source")
+ assertEquals("non-pdf", source)
+ }
+
+ @Test
+ fun `GIVEN a save to pdf request WHEN it the action begins THEN tapped telemetry is sent`() =
+ runTestOnMain {
+ val middleware = SaveToPDFMiddleware(testContext)
+ val mockEngineSession: EngineSession = mockk<EngineSession>().apply {
+ every {
+ checkForPdfViewer(any(), any())
+ } answers {
+ firstArg<(Boolean) -> Unit>().invoke(false)
+ }
+ }
+ val browserStore = BrowserStore(
+ middleware = listOf(middleware),
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab(
+ url = "https://mozilla.org",
+ id = "14",
+ engineSession = mockEngineSession,
+ ),
+ ),
+ ),
+ )
+ browserStore.dispatch(EngineAction.SaveToPdfAction("14"))
+ browserStore.waitUntilIdle()
+ testScheduler.advanceUntilIdle()
+ val response = Events.saveToPdfTapped.testGetValue()
+ assertNotNull(response)
+ val source = response?.firstOrNull()?.extra?.get("source")
+ assertEquals("non-pdf", source)
+ }
+
+ @Test
+ fun `GIVEN a save as pdf exception THEN should calculate the correct failure reason for telemetry`() = runTestOnMain {
+ val mockContext: Context = mock()
+ val middleware = SaveToPDFMiddleware(mockContext)
+ val noSettingsService = middleware.telemetryErrorReason(MockGeckoPrintException())
+ assertEquals("no_settings_service", noSettingsService)
+ val ioException = middleware.telemetryErrorReason(IOException())
+ assertEquals("io_error", ioException)
+ val other = middleware.telemetryErrorReason(Exception())
+ assertEquals("unknown", other)
+ }
+
+ @Test
+ fun `GIVEN a save as pdf page type THEN should calculate the correct page source for telemetry`() = runTestOnMain {
+ val mockContext: Context = mock()
+ val middleware = SaveToPDFMiddleware(mockContext)
+ assertEquals("pdf", middleware.telemetrySource(isPdfViewer = true))
+ assertEquals("non-pdf", middleware.telemetrySource(isPdfViewer = false))
+ assertEquals("unknown", middleware.telemetrySource(isPdfViewer = null))
+ }
+
+ @Test
+ fun `GIVEN a print request WHEN it fails unexpectedly THEN unknown failure telemetry is sent AND a snackbar error is shown`() = runTestOnMain {
+ val exceptionToThrow = RuntimeException("No Print Spooler")
+ val middleware = SaveToPDFMiddleware(testContext)
+ val mockEngineSession: EngineSession = mockk<EngineSession>().apply {
+ every {
+ checkForPdfViewer(any(), any())
+ } answers {
+ secondArg<(Throwable) -> Unit>().invoke(exceptionToThrow)
+ }
+ }
+ val browserStore = BrowserStore(
+ middleware = listOf(middleware),
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab(
+ url = "https://mozilla.org",
+ id = "14",
+ engineSession = mockEngineSession,
+ ),
+ ),
+ ),
+ )
+ browserStore.dispatch(
+ EngineAction.PrintContentExceptionAction("14", true, exceptionToThrow),
+ )
+ browserStore.waitUntilIdle()
+ testScheduler.advanceUntilIdle()
+ val response = Events.printFailure.testGetValue()?.firstOrNull()
+ assertNotNull(response)
+ val reason = response?.extra?.get("reason")
+ assertEquals("unknown", reason)
+ val source = response?.extra?.get("source")
+ assertEquals("unknown", source)
+ verify {
+ appStore.dispatch(
+ AppAction.UpdateStandardSnackbarErrorAction(
+ StandardSnackbarError(
+ testContext.getString(R.string.unable_to_print_page_error),
+ ),
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN a print request WHEN it fails due to print exception THEN print exception failure telemetry is sent AND a snackbar error is shown`() = runTestOnMain {
+ val exceptionToThrow = MockGeckoPrintException()
+ val middleware = SaveToPDFMiddleware(testContext)
+ val mockEngineSession: EngineSession = mockk<EngineSession>().apply {
+ every {
+ checkForPdfViewer(any(), any())
+ } answers {
+ secondArg<(Throwable) -> Unit>().invoke(exceptionToThrow)
+ }
+ }
+ val browserStore = BrowserStore(
+ middleware = listOf(middleware),
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab(
+ url = "https://mozilla.org",
+ id = "14",
+ engineSession = mockEngineSession,
+ ),
+ ),
+ ),
+ )
+ browserStore.dispatch(EngineAction.PrintContentExceptionAction("14", true, exceptionToThrow))
+ browserStore.waitUntilIdle()
+ testScheduler.advanceUntilIdle()
+ val response = Events.printFailure.testGetValue()?.firstOrNull()
+ assertNotNull(response)
+ val reason = response?.extra?.get("reason")
+ assertEquals("no_settings_service", reason)
+ val source = response?.extra?.get("source")
+ assertEquals("unknown", source)
+ verify {
+ appStore.dispatch(
+ AppAction.UpdateStandardSnackbarErrorAction(
+ StandardSnackbarError(
+ testContext.getString(R.string.unable_to_print_page_error),
+ ),
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN a print request WHEN it completes THEN completed telemetry is sent`() = runTestOnMain {
+ val middleware = SaveToPDFMiddleware(testContext)
+ val mockEngineSession: EngineSession = mockk<EngineSession>().apply {
+ every {
+ checkForPdfViewer(any(), any())
+ } answers {
+ firstArg<(Boolean) -> Unit>().invoke(true)
+ }
+ }
+ val browserStore = BrowserStore(
+ middleware = listOf(middleware),
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab(
+ url = "https://mozilla.org",
+ id = "14",
+ engineSession = mockEngineSession,
+ ),
+ ),
+ ),
+ )
+ browserStore.dispatch(EngineAction.PrintContentCompletedAction("14"))
+ browserStore.waitUntilIdle()
+ testScheduler.advanceUntilIdle()
+ val response = Events.printCompleted.testGetValue()
+ assertNotNull(response)
+ val source = response?.firstOrNull()?.extra?.get("source")
+ assertEquals("pdf", source)
+ }
+
+ @Test
+ fun `GIVEN a print request WHEN it the action begins THEN tapped telemetry is sent`() = runTestOnMain {
+ val middleware = SaveToPDFMiddleware(testContext)
+ val mockEngineSession: EngineSession = mockk<EngineSession>().apply {
+ every {
+ checkForPdfViewer(any(), any())
+ } answers {
+ firstArg<(Boolean) -> Unit>().invoke(false)
+ }
+ }
+ val browserStore = BrowserStore(
+ middleware = listOf(middleware),
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab(
+ url = "https://mozilla.org",
+ id = "14",
+ engineSession = mockEngineSession,
+ ),
+ ),
+ ),
+ )
+ browserStore.dispatch(EngineAction.PrintContentAction("14"))
+ browserStore.waitUntilIdle()
+ testScheduler.advanceUntilIdle()
+ val response = Events.printTapped.testGetValue()
+ assertNotNull(response)
+ val source = response?.firstOrNull()?.extra?.get("source")
+ assertEquals("non-pdf", source)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/ShareCloseViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/ShareCloseViewTest.kt
new file mode 100644
index 0000000000..14df227c75
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/ShareCloseViewTest.kt
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.share
+
+import android.view.ViewGroup
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.recyclerview.widget.LinearLayoutManager
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.databinding.ShareCloseBinding
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.share.listadapters.ShareTabsAdapter
+
+@RunWith(FenixRobolectricTestRunner::class)
+class ShareCloseViewTest {
+
+ private lateinit var container: ViewGroup
+ private lateinit var interactor: ShareCloseInteractor
+
+ @Before
+ fun setup() {
+ container = ConstraintLayout(testContext)
+ interactor = mockk(relaxUnitFun = true)
+ }
+
+ @Test
+ fun `binds adapter and close button`() {
+ ShareCloseView(container, interactor)
+ val binding = ShareCloseBinding.bind(container)
+
+ assertTrue(binding.sharedSiteList.layoutManager is LinearLayoutManager)
+ assertTrue(binding.sharedSiteList.adapter is ShareTabsAdapter)
+
+ binding.closeButton.performClick()
+ verify { interactor.onShareClosed() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/ShareControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/ShareControllerTest.kt
new file mode 100644
index 0000000000..a997014044
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/ShareControllerTest.kt
@@ -0,0 +1,699 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.share
+
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import androidx.navigation.NavController
+import com.google.android.material.snackbar.Snackbar
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.spyk
+import io.mockk.verify
+import io.mockk.verifyOrder
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.test.advanceUntilIdle
+import mozilla.components.concept.engine.prompt.ShareData
+import mozilla.components.concept.sync.Device
+import mozilla.components.concept.sync.DeviceType
+import mozilla.components.concept.sync.TabData
+import mozilla.components.feature.accounts.push.SendTabUseCases
+import mozilla.components.feature.session.SessionUseCases
+import mozilla.components.feature.share.RecentAppsStorage
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.Events
+import org.mozilla.fenix.GleanMetrics.SyncAccount
+import org.mozilla.fenix.R
+import org.mozilla.fenix.components.FenixSnackbar
+import org.mozilla.fenix.components.accounts.FenixFxAEntryPoint
+import org.mozilla.fenix.ext.nav
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.share.listadapters.AppShareOption
+
+@RunWith(FenixRobolectricTestRunner::class)
+class ShareControllerTest {
+ // Need a valid context to retrieve Strings for example, but we also need it to return our "metrics"
+ private val context: Context = spyk(testContext)
+ private val shareSubject = "shareSubject"
+ private val shareData = listOf(
+ ShareData(url = "url0", title = "title0"),
+ ShareData(url = "url1", title = "title1"),
+ )
+
+ // Navigation between app fragments uses ShareTab as arguments. SendTabUseCases uses TabData.
+ private val tabsData = listOf(
+ TabData("title0", "url0"),
+ TabData("title1", "url1"),
+ )
+ private val textToShare = "${shareData[0].url}\n\n${shareData[1].url}"
+ private val sendTabUseCases = mockk<SendTabUseCases>(relaxed = true)
+ private val saveToPdfUseCase = mockk<SessionUseCases.SaveToPdfUseCase>(relaxed = true)
+ private val printUseCase = mockk<SessionUseCases.PrintContentUseCase>(relaxed = true)
+ private val snackbar = mockk<FenixSnackbar>(relaxed = true)
+ private val navController = mockk<NavController>(relaxed = true)
+ private val dismiss = mockk<(ShareController.Result) -> Unit>(relaxed = true)
+ private val recentAppStorage = mockk<RecentAppsStorage>(relaxed = true)
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val testDispatcher = coroutinesTestRule.testDispatcher
+ private val testCoroutineScope = coroutinesTestRule.scope
+ private val controller = DefaultShareController(
+ context, shareSubject, shareData, sendTabUseCases, saveToPdfUseCase, printUseCase, snackbar, navController,
+ recentAppStorage, testCoroutineScope, testDispatcher, FenixFxAEntryPoint.ShareMenu, dismiss,
+ )
+
+ @Test
+ fun `handleShareClosed should call a passed in delegate to close this`() {
+ controller.handleShareClosed()
+
+ verify { dismiss(ShareController.Result.DISMISSED) }
+ }
+
+ @Test
+ fun `handleShareToApp should start a new sharing activity and close this`() = runTestOnMain {
+ assertNull(Events.shareToApp.testGetValue())
+
+ val appPackageName = "package"
+ val appClassName = "activity"
+ val appShareOption = AppShareOption("app", mockk(), appPackageName, appClassName)
+ val shareIntent = slot<Intent>()
+ // Our share Intent uses `FLAG_ACTIVITY_NEW_TASK` but when resolving the startActivity call
+ // needed for capturing the actual Intent used the `slot` one doesn't have this flag so we
+ // need to use an Activity Context.
+ val activityContext: Context = mockk<Activity>()
+ val testController = DefaultShareController(
+ activityContext, shareSubject, shareData, mockk(), mockk(),
+ mockk(), mockk(), mockk(), recentAppStorage, testCoroutineScope, testDispatcher,
+ FenixFxAEntryPoint.ShareMenu, dismiss,
+ )
+ every { activityContext.startActivity(capture(shareIntent)) } just Runs
+ every { recentAppStorage.updateRecentApp(appShareOption.activityName) } just Runs
+
+ testController.handleShareToApp(appShareOption)
+ advanceUntilIdle()
+
+ assertEquals("shareToApp event only called once", 1, Events.shareToApp.testGetValue()?.size)
+ assertEquals("other", Events.shareToApp.testGetValue()?.last()?.extra?.getValue("app_package"))
+
+ // Check that the Intent used for querying apps has the expected structure
+ assertTrue(shareIntent.isCaptured)
+ assertEquals(Intent.ACTION_SEND, shareIntent.captured.action)
+ @Suppress("DEPRECATION")
+ assertEquals(shareSubject, shareIntent.captured.extras!![Intent.EXTRA_SUBJECT])
+ @Suppress("DEPRECATION")
+ assertEquals(textToShare, shareIntent.captured.extras!![Intent.EXTRA_TEXT])
+ assertEquals("text/plain", shareIntent.captured.type)
+ assertEquals(Intent.FLAG_ACTIVITY_NEW_DOCUMENT + Intent.FLAG_ACTIVITY_MULTIPLE_TASK, shareIntent.captured.flags)
+ assertEquals(appPackageName, shareIntent.captured.component!!.packageName)
+ assertEquals(appClassName, shareIntent.captured.component!!.className)
+
+ verify { recentAppStorage.updateRecentApp(appShareOption.activityName) }
+ verifyOrder {
+ activityContext.startActivity(shareIntent.captured)
+ dismiss(ShareController.Result.SUCCESS)
+ }
+ }
+
+ @Test
+ fun `handleShareToApp should record to telemetry packages which are in allowed list`() {
+ assertNull(Events.shareToApp.testGetValue())
+
+ val appPackageName = "com.android.bluetooth"
+ val appClassName = "activity"
+ val appShareOption = AppShareOption("app", mockk(), appPackageName, appClassName)
+ val shareIntent = slot<Intent>()
+ // Our share Intent uses `FLAG_ACTIVITY_NEW_TASK` but when resolving the startActivity call
+ // needed for capturing the actual Intent used the `slot` one doesn't have this flag so we
+ // need to use an Activity Context.
+ val activityContext: Context = mockk<Activity>()
+ val testController = DefaultShareController(
+ activityContext, shareSubject, shareData, mockk(), mockk(),
+ mockk(), mockk(), mockk(), recentAppStorage, testCoroutineScope, testDispatcher,
+ FenixFxAEntryPoint.ShareMenu, dismiss,
+ )
+
+ every { activityContext.startActivity(capture(shareIntent)) } just Runs
+ every { recentAppStorage.updateRecentApp(appShareOption.activityName) } just Runs
+
+ testController.handleShareToApp(appShareOption)
+
+ assertEquals("shareToApp event only called once", 1, Events.shareToApp.testGetValue()?.size)
+ assertEquals("com.android.bluetooth", Events.shareToApp.testGetValue()?.last()?.extra?.getValue("app_package"))
+ }
+
+ @Test
+ fun `handleShareToApp should record to telemetry as other when app package not in allowed list`() {
+ assertNull(Events.shareToApp.testGetValue())
+
+ val appPackageName = "com.package.record.not.allowed"
+ val appClassName = "activity"
+ val appShareOption = AppShareOption("app", mockk(), appPackageName, appClassName)
+ val shareIntent = slot<Intent>()
+ // Our share Intent uses `FLAG_ACTIVITY_NEW_TASK` but when resolving the startActivity call
+ // needed for capturing the actual Intent used the `slot` one doesn't have this flag so we
+ // need to use an Activity Context.
+ val activityContext: Context = mockk<Activity>()
+ val testController = DefaultShareController(
+ activityContext, shareSubject, shareData, mockk(), mockk(),
+ mockk(), mockk(), mockk(), recentAppStorage, testCoroutineScope, testDispatcher,
+ FenixFxAEntryPoint.ShareMenu, dismiss,
+ )
+
+ every { activityContext.startActivity(capture(shareIntent)) } just Runs
+ every { recentAppStorage.updateRecentApp(appShareOption.activityName) } just Runs
+
+ testController.handleShareToApp(appShareOption)
+
+ // Only called once and package is not in the allowed telemetry list so this should record "other"
+ assertEquals("shareToApp event only called once", 1, Events.shareToApp.testGetValue()?.size)
+ assertEquals("other", Events.shareToApp.testGetValue()?.last()?.extra?.getValue("app_package"))
+ }
+
+ @Test
+ fun `handleShareToApp should dismiss with an error start when a security exception occurs`() {
+ val appPackageName = "package"
+ val appClassName = "activity"
+ val appShareOption = AppShareOption("app", mockk(), appPackageName, appClassName)
+ val shareIntent = slot<Intent>()
+ // Our share Intent uses `FLAG_ACTIVITY_NEW_TASK` but when resolving the startActivity call
+ // needed for capturing the actual Intent used the `slot` one doesn't have this flag so we
+ // need to use an Activity Context.
+ val activityContext: Context = mockk<Activity>()
+ val testController = DefaultShareController(
+ context = activityContext,
+ shareSubject = shareSubject,
+ shareData = shareData,
+ sendTabUseCases = mockk(),
+ saveToPdfUseCase = mockk(),
+ printUseCase = mockk(),
+ snackbar = snackbar,
+ navController = mockk(),
+ recentAppsStorage = recentAppStorage,
+ viewLifecycleScope = testCoroutineScope,
+ dispatcher = testDispatcher,
+ dismiss = dismiss,
+ )
+ every { recentAppStorage.updateRecentApp(appShareOption.activityName) } just Runs
+ every { activityContext.startActivity(capture(shareIntent)) } throws SecurityException()
+ every { activityContext.getString(R.string.share_error_snackbar) } returns "Cannot share to this app"
+
+ testController.handleShareToApp(appShareOption)
+
+ verifyOrder {
+ activityContext.startActivity(shareIntent.captured)
+ snackbar.setText("Cannot share to this app")
+ snackbar.show()
+ dismiss(ShareController.Result.SHARE_ERROR)
+ }
+ }
+
+ @Test
+ fun `handleShareToApp should dismiss with an error start when a ActivityNotFoundException occurs`() {
+ val appPackageName = "package"
+ val appClassName = "activity"
+ val appShareOption = AppShareOption("app", mockk(), appPackageName, appClassName)
+ val shareIntent = slot<Intent>()
+ // Our share Intent uses `FLAG_ACTIVITY_NEW_TASK` but when resolving the startActivity call
+ // needed for capturing the actual Intent used the `slot` one doesn't have this flag so we
+ // need to use an Activity Context.
+ val activityContext: Context = mockk<Activity>()
+ val testController = DefaultShareController(
+ context = activityContext,
+ shareSubject = shareSubject,
+ shareData = shareData,
+ sendTabUseCases = mockk(),
+ saveToPdfUseCase = mockk(),
+ printUseCase = mockk(),
+ snackbar = snackbar,
+ navController = mockk(),
+ recentAppsStorage = recentAppStorage,
+ viewLifecycleScope = testCoroutineScope,
+ dispatcher = testDispatcher,
+ dismiss = dismiss,
+ )
+ every { recentAppStorage.updateRecentApp(appShareOption.activityName) } just Runs
+ every { activityContext.startActivity(capture(shareIntent)) } throws ActivityNotFoundException()
+ every { activityContext.getString(R.string.share_error_snackbar) } returns "Cannot share to this app"
+
+ testController.handleShareToApp(appShareOption)
+
+ verifyOrder {
+ activityContext.startActivity(shareIntent.captured)
+ snackbar.setText("Cannot share to this app")
+ snackbar.show()
+ dismiss(ShareController.Result.SHARE_ERROR)
+ }
+ }
+
+ @Test
+ fun `WHEN handleSaveToPDF close the dialog and save the page to pdf`() {
+ val testController = DefaultShareController(
+ context = mockk(),
+ shareSubject = shareSubject,
+ shareData = shareData,
+ sendTabUseCases = mockk(),
+ saveToPdfUseCase = saveToPdfUseCase,
+ printUseCase = mockk(),
+ snackbar = snackbar,
+ navController = mockk(),
+ recentAppsStorage = recentAppStorage,
+ viewLifecycleScope = testCoroutineScope,
+ dispatcher = testDispatcher,
+ dismiss = dismiss,
+ )
+
+ testController.handleSaveToPDF("tabID")
+
+ verify {
+ saveToPdfUseCase.invoke("tabID")
+ dismiss(ShareController.Result.DISMISSED)
+ }
+ }
+
+ @Test
+ fun `WHEN handlePrint close the dialog and print the page AND send tapped telemetry`() {
+ val testController = DefaultShareController(
+ context = mockk(),
+ shareSubject = shareSubject,
+ shareData = shareData,
+ sendTabUseCases = mockk(),
+ saveToPdfUseCase = mockk(),
+ printUseCase = printUseCase,
+ snackbar = snackbar,
+ navController = mockk(),
+ recentAppsStorage = recentAppStorage,
+ viewLifecycleScope = testCoroutineScope,
+ dispatcher = testDispatcher,
+ dismiss = dismiss,
+ )
+
+ testController.handlePrint("tabID")
+
+ verify {
+ printUseCase.invoke("tabID")
+ dismiss(ShareController.Result.DISMISSED)
+ }
+
+ assertNotNull(Events.shareMenuAction.testGetValue())
+ val printTapped = Events.shareMenuAction.testGetValue()!!
+ assertEquals(1, printTapped.size)
+ assertEquals("print", printTapped.single().extra?.getValue("item"))
+ }
+
+ @Test
+ fun `getShareSubject should return the shareSubject when shareSubject is not null`() {
+ val activityContext: Context = mockk<Activity>()
+ val testController = DefaultShareController(
+ context = activityContext,
+ shareSubject = shareSubject,
+ shareData = shareData,
+ sendTabUseCases = mockk(),
+ saveToPdfUseCase = mockk(),
+ printUseCase = mockk(),
+ snackbar = mockk(),
+ navController = mockk(),
+ recentAppsStorage = recentAppStorage,
+ viewLifecycleScope = testCoroutineScope,
+ dispatcher = testDispatcher,
+ dismiss = dismiss,
+ )
+
+ assertEquals(shareSubject, testController.getShareSubject())
+ }
+
+ @Test
+ fun `getShareSubject should return a combination of non-null titles when shareSubject is null`() {
+ val activityContext: Context = mockk<Activity>()
+ val testController = DefaultShareController(
+ context = activityContext,
+ shareSubject = null,
+ shareData = shareData,
+ sendTabUseCases = mockk(),
+ saveToPdfUseCase = mockk(),
+ printUseCase = mockk(),
+ snackbar = mockk(),
+ navController = mockk(),
+ recentAppsStorage = recentAppStorage,
+ viewLifecycleScope = testCoroutineScope,
+ dispatcher = testDispatcher,
+ dismiss = dismiss,
+ )
+
+ assertEquals("title0, title1", testController.getShareSubject())
+ }
+
+ @Test
+ fun `getShareSubject should return just the not null titles string when shareSubject is null`() {
+ val activityContext: Context = mockk<Activity>()
+ val partialTitlesShareData = listOf(
+ ShareData(url = "url0", title = null),
+ ShareData(url = "url1", title = "title1"),
+ )
+ val testController = DefaultShareController(
+ context = activityContext,
+ shareSubject = null,
+ shareData = partialTitlesShareData,
+ sendTabUseCases = mockk(),
+ saveToPdfUseCase = mockk(),
+ printUseCase = mockk(),
+ snackbar = mockk(),
+ navController = mockk(),
+ recentAppsStorage = recentAppStorage,
+ viewLifecycleScope = testCoroutineScope,
+ dispatcher = testDispatcher,
+ dismiss = dismiss,
+ )
+
+ assertEquals("title1", testController.getShareSubject())
+ }
+
+ @Test
+ fun `getShareSubject should return empty string when shareSubject and all titles are null`() {
+ val activityContext: Context = mockk<Activity>()
+ val noTitleShareData = listOf(
+ ShareData(url = "url0", title = null),
+ ShareData(url = "url1", title = null),
+ )
+ val testController = DefaultShareController(
+ context = activityContext,
+ shareSubject = null,
+ shareData = noTitleShareData,
+ sendTabUseCases = mockk(),
+ saveToPdfUseCase = mockk(),
+ printUseCase = mockk(),
+ snackbar = mockk(),
+ navController = mockk(),
+ recentAppsStorage = recentAppStorage,
+ viewLifecycleScope = testCoroutineScope,
+ dispatcher = testDispatcher,
+ dismiss = dismiss,
+ )
+
+ assertEquals("", testController.getShareSubject())
+ }
+
+ @Test
+ fun `getShareSubject should return empty string when shareSubject is null and and all titles are empty`() {
+ val activityContext: Context = mockk<Activity>()
+ val noTitleShareData = listOf(
+ ShareData(url = "url0", title = ""),
+ ShareData(url = "url1", title = ""),
+ )
+ val testController = DefaultShareController(
+ context = activityContext,
+ shareSubject = null,
+ shareData = noTitleShareData,
+ sendTabUseCases = mockk(),
+ saveToPdfUseCase = mockk(),
+ printUseCase = mockk(),
+ snackbar = mockk(),
+ navController = mockk(),
+ recentAppsStorage = recentAppStorage,
+ viewLifecycleScope = testCoroutineScope,
+ dispatcher = testDispatcher,
+ dismiss = dismiss,
+ )
+
+ assertEquals("", testController.getShareSubject())
+ }
+
+ @Test
+ @Suppress("DeferredResultUnused")
+ fun `handleShareToDevice should share to account device, inform callbacks and dismiss`() {
+ val deviceToShareTo = Device(
+ "deviceId",
+ "deviceName",
+ DeviceType.UNKNOWN,
+ false,
+ 0L,
+ emptyList(),
+ false,
+ null,
+ )
+ val deviceId = slot<String>()
+ val tabsShared = slot<List<TabData>>()
+
+ every { sendTabUseCases.sendToDeviceAsync(any(), any<List<TabData>>()) } returns CompletableDeferred(true)
+ every { navController.currentDestination?.id } returns R.id.shareFragment
+
+ controller.handleShareToDevice(deviceToShareTo)
+
+ assertNotNull(SyncAccount.sendTab.testGetValue())
+ assertEquals(1, SyncAccount.sendTab.testGetValue()!!.size)
+ assertNull(SyncAccount.sendTab.testGetValue()!!.single().extra)
+
+ verifyOrder {
+ sendTabUseCases.sendToDeviceAsync(capture(deviceId), capture(tabsShared))
+ dismiss(ShareController.Result.SUCCESS)
+ }
+
+ assertTrue(deviceId.isCaptured)
+ assertEquals(deviceToShareTo.id, deviceId.captured)
+ assertTrue(tabsShared.isCaptured)
+ assertEquals(tabsData, tabsShared.captured)
+ }
+
+ @Test
+ @Suppress("DeferredResultUnused")
+ fun `handleShareToAllDevices calls handleShareToDevice multiple times`() {
+ every { sendTabUseCases.sendToAllAsync(any<List<TabData>>()) } returns CompletableDeferred(true)
+ every { navController.currentDestination?.id } returns R.id.shareFragment
+
+ val devicesToShareTo = listOf(
+ Device(
+ "deviceId0",
+ "deviceName0",
+ DeviceType.UNKNOWN,
+ false,
+ 0L,
+ emptyList(),
+ false,
+ null,
+ ),
+ Device(
+ "deviceId1",
+ "deviceName1",
+ DeviceType.UNKNOWN,
+ true,
+ 1L,
+ emptyList(),
+ false,
+ null,
+ ),
+ )
+ val tabsShared = slot<List<TabData>>()
+
+ controller.handleShareToAllDevices(devicesToShareTo)
+
+ verifyOrder {
+ sendTabUseCases.sendToAllAsync(capture(tabsShared))
+ dismiss(ShareController.Result.SUCCESS)
+ }
+
+ // SendTabUseCases should send a the `shareTabs` mapped to tabData
+ assertTrue(tabsShared.isCaptured)
+ assertEquals(tabsData, tabsShared.captured)
+ }
+
+ @Test
+ fun `handleSignIn should navigate to the Sync Fragment and dismiss this one`() {
+ controller.handleSignIn()
+
+ assertNotNull(SyncAccount.signInToSendTab.testGetValue())
+ assertEquals(1, SyncAccount.signInToSendTab.testGetValue()!!.size)
+ assertNull(SyncAccount.signInToSendTab.testGetValue()!!.single().extra)
+
+ verifyOrder {
+ navController.nav(
+ R.id.shareFragment,
+ ShareFragmentDirections.actionGlobalTurnOnSync(
+ entrypoint = FenixFxAEntryPoint.ShareMenu,
+ ),
+ )
+ dismiss(ShareController.Result.DISMISSED)
+ }
+ }
+
+ @Test
+ fun `handleReauth should navigate to the Account Problem Fragment and dismiss this one`() {
+ controller.handleReauth()
+
+ verifyOrder {
+ navController.nav(
+ R.id.shareFragment,
+ ShareFragmentDirections.actionGlobalAccountProblemFragment(
+ entrypoint = FenixFxAEntryPoint.ShareMenu,
+ ),
+ )
+ dismiss(ShareController.Result.DISMISSED)
+ }
+ }
+
+ @Test
+ fun `showSuccess should show a snackbar with a success message`() {
+ val expectedMessage = controller.getSuccessMessage()
+ val expectedTimeout = Snackbar.LENGTH_SHORT
+
+ controller.showSuccess()
+
+ verify {
+ snackbar.setText(expectedMessage)
+ snackbar.setLength(expectedTimeout)
+ }
+ }
+
+ @Test
+ fun `showFailureWithRetryOption should show a snackbar with a retry action`() {
+ val expectedMessage = context.getString(R.string.sync_sent_tab_error_snackbar)
+ val expectedTimeout = Snackbar.LENGTH_LONG
+ val operation: () -> Unit = { println("Hello World") }
+ val expectedRetryMessage =
+ context.getString(R.string.sync_sent_tab_error_snackbar_action)
+
+ controller.showFailureWithRetryOption(operation)
+
+ verify {
+ snackbar.apply {
+ setText(expectedMessage)
+ setLength(expectedTimeout)
+ setAction(expectedRetryMessage, operation)
+ setAppropriateBackground(true)
+ }
+ }
+ }
+
+ @Test
+ fun `getSuccessMessage should return different strings depending on the number of shared tabs`() {
+ val controllerWithOneSharedTab = DefaultShareController(
+ context = context,
+ shareSubject = shareSubject,
+ shareData = listOf(ShareData(url = "url0", title = "title0")),
+ sendTabUseCases = mockk(),
+ saveToPdfUseCase = mockk(),
+ printUseCase = mockk(),
+ snackbar = mockk(),
+ navController = mockk(),
+ recentAppsStorage = mockk(),
+ viewLifecycleScope = mockk(),
+ dispatcher = mockk(),
+ dismiss = mockk(),
+ )
+ val controllerWithMoreSharedTabs = controller
+ val expectedTabSharedMessage = context.getString(R.string.sync_sent_tab_snackbar)
+ val expectedTabsSharedMessage = context.getString(R.string.sync_sent_tabs_snackbar)
+
+ val tabSharedMessage = controllerWithOneSharedTab.getSuccessMessage()
+ val tabsSharedMessage = controllerWithMoreSharedTabs.getSuccessMessage()
+
+ assertNotEquals(tabsSharedMessage, tabSharedMessage)
+ assertEquals(expectedTabSharedMessage, tabSharedMessage)
+ assertEquals(expectedTabsSharedMessage, tabsSharedMessage)
+ }
+
+ @Test
+ fun `getShareText should respect concatenate shared tabs urls`() {
+ assertEquals(textToShare, controller.getShareText())
+ }
+
+ @Test
+ fun `getShareText attempts to use original URL for reader pages`() {
+ val shareData = listOf(
+ ShareData(url = "moz-extension://eb8df45a-895b-4f3a-896a-c0c71ae4/page.html"),
+ ShareData(url = "moz-extension://eb8df45a-895b-4f3a-896a-c0c71ae5/page.html?url=url0"),
+ ShareData(url = "url1"),
+ )
+ val controller = DefaultShareController(
+ context = context,
+ shareSubject = shareSubject,
+ shareData = shareData,
+ sendTabUseCases = sendTabUseCases,
+ saveToPdfUseCase = mockk(),
+ printUseCase = mockk(),
+ snackbar = snackbar,
+ navController = navController,
+ recentAppsStorage = recentAppStorage,
+ viewLifecycleScope = testCoroutineScope,
+ dispatcher = testDispatcher,
+ dismiss = dismiss,
+ )
+
+ val expectedShareText = "${shareData[0].url}\n\nurl0\n\n${shareData[2].url}"
+ assertEquals(expectedShareText, controller.getShareText())
+ }
+
+ @Test
+ fun `getShareSubject will return 'shareSubject' if that is non null`() {
+ assertEquals(shareSubject, controller.getShareSubject())
+ }
+
+ @Test
+ fun `getShareSubject will return a concatenation of tab titles if 'shareSubject' is null`() {
+ val controller = DefaultShareController(
+ context = context,
+ shareSubject = null,
+ shareData = shareData,
+ sendTabUseCases = sendTabUseCases,
+ saveToPdfUseCase = mockk(),
+ printUseCase = mockk(),
+ snackbar = snackbar,
+ navController = navController,
+ recentAppsStorage = recentAppStorage,
+ viewLifecycleScope = testCoroutineScope,
+ dispatcher = testDispatcher,
+ dismiss = dismiss,
+ )
+
+ assertEquals("title0, title1", controller.getShareSubject())
+ }
+
+ @Test
+ fun `ShareTab#toTabData maps a list of ShareTab to a TabData list`() {
+ var tabData: List<TabData>
+
+ with(controller) {
+ tabData = shareData.toTabData()
+ }
+
+ assertEquals(tabsData, tabData)
+ }
+
+ @Test
+ fun `ShareTab#toTabData creates a data url from text if no url is specified`() {
+ var tabData: List<TabData>
+ val expected = listOf(
+ TabData(title = "title0", url = ""),
+ TabData(title = "title1", url = "data:,Hello%2C%20World!"),
+ )
+
+ with(controller) {
+ tabData = listOf(
+ ShareData(title = "title0"),
+ ShareData(title = "title1", text = "Hello, World!"),
+ ).toTabData()
+ }
+
+ assertEquals(expected, tabData)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/ShareInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/ShareInteractorTest.kt
new file mode 100644
index 0000000000..db457181ea
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/ShareInteractorTest.kt
@@ -0,0 +1,84 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.share
+
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.concept.sync.Device
+import org.junit.Test
+import org.mozilla.fenix.share.listadapters.AppShareOption
+
+class ShareInteractorTest {
+ private val controller = mockk<ShareController>(relaxed = true)
+ private val interactor = ShareInteractor(controller)
+
+ @Test
+ fun onShareClosed() {
+ interactor.onShareClosed()
+
+ verify { controller.handleShareClosed() }
+ }
+
+ @Test
+ fun onSignIn() {
+ interactor.onSignIn()
+
+ verify { controller.handleSignIn() }
+ }
+
+ @Test
+ fun onReauth() {
+ interactor.onReauth()
+
+ verify { controller.handleReauth() }
+ }
+
+ @Test
+ fun onAddNewDevice() {
+ interactor.onAddNewDevice()
+
+ verify { controller.handleAddNewDevice() }
+ }
+
+ @Test
+ fun onShareToDevice() {
+ val device = mockk<Device>()
+
+ interactor.onShareToDevice(device)
+
+ verify { controller.handleShareToDevice(device) }
+ }
+
+ @Test
+ fun onSendToAllDevices() {
+ val devices = emptyList<Device>()
+
+ interactor.onShareToAllDevices(devices)
+
+ verify { controller.handleShareToAllDevices(devices) }
+ }
+
+ @Test
+ fun onShareToApp() {
+ val app = mockk<AppShareOption>()
+
+ interactor.onShareToApp(app)
+
+ verify { controller.handleShareToApp(app) }
+ }
+
+ @Test
+ fun `WHEN onSaveToPDF is call THEN call handleSaveToPDF`() {
+ interactor.onSaveToPDF("tabID")
+
+ verify { controller.handleSaveToPDF("tabID") }
+ }
+
+ @Test
+ fun `WHEN onPrint is call THEN call handlePrint`() {
+ interactor.onPrint("tabID")
+ verify { controller.handlePrint("tabID") }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/ShareViewModelTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/ShareViewModelTest.kt
new file mode 100644
index 0000000000..0936dcc8dc
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/ShareViewModelTest.kt
@@ -0,0 +1,219 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.share
+
+import android.app.Application
+import android.content.pm.ActivityInfo
+import android.content.pm.PackageManager
+import android.content.pm.ResolveInfo
+import android.graphics.drawable.Drawable
+import android.net.ConnectivityManager
+import androidx.core.content.getSystemService
+import androidx.lifecycle.asFlow
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.spyk
+import io.mockk.verify
+import kotlinx.coroutines.flow.first
+import mozilla.components.feature.share.RecentApp
+import mozilla.components.feature.share.RecentAppsStorage
+import mozilla.components.service.fxa.manager.FxaAccountManager
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.ext.application
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.isOnline
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.share.DefaultShareController.Companion.ACTION_COPY_LINK_TO_CLIPBOARD
+import org.mozilla.fenix.share.ShareViewModel.Companion.RECENT_APPS_LIMIT
+import org.mozilla.fenix.share.listadapters.AppShareOption
+import org.mozilla.fenix.share.listadapters.SyncShareOption
+import org.robolectric.shadows.ShadowLooper
+
+@RunWith(FenixRobolectricTestRunner::class)
+class ShareViewModelTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val testIoDispatcher = coroutinesTestRule.testDispatcher
+
+ private val packageName = "org.mozilla.fenix"
+ private lateinit var application: Application
+ private lateinit var packageManager: PackageManager
+ private lateinit var connectivityManager: ConnectivityManager
+ private lateinit var fxaAccountManager: FxaAccountManager
+ private lateinit var viewModel: ShareViewModel
+ private lateinit var storage: RecentAppsStorage
+
+ @Before
+ fun setup() {
+ application = spyk(testContext.application)
+ packageManager = mockk(relaxed = true)
+ connectivityManager = mockk(relaxed = true)
+ fxaAccountManager = mockk(relaxed = true)
+ storage = mockk(relaxUnitFun = true)
+
+ mockkStatic("org.mozilla.fenix.ext.ConnectivityManagerKt")
+
+ every { application.packageName } returns packageName
+ every { application.packageManager } returns packageManager
+ every { application.getSystemService<ConnectivityManager>() } returns connectivityManager
+ every { application.components.backgroundServices.accountManager } returns fxaAccountManager
+
+ viewModel = spyk(
+ ShareViewModel(application).apply {
+ this.ioDispatcher = testIoDispatcher
+ },
+ )
+ }
+
+ @Test
+ fun `liveData should be initialized as empty list`() {
+ assertEquals(emptyList<SyncShareOption>(), viewModel.devicesList.value)
+ assertEquals(emptyList<AppShareOption>(), viewModel.appsList.value)
+ }
+
+ @Test
+ fun `loadDevicesAndApps`() = runTestOnMain {
+ val appOptions = listOf(
+ AppShareOption("Label", mockk(), "Package", "Activity"),
+ )
+
+ val appEntity = mockk<RecentApp>()
+ every { appEntity.activityName } returns "Activity"
+ val recentAppOptions = listOf(appEntity)
+ every { storage.updateDatabaseWithNewApps(appOptions.map { app -> app.packageName }) } just Runs
+ every { storage.getRecentAppsUpTo(RECENT_APPS_LIMIT) } returns recentAppOptions
+
+ every { viewModel.buildAppsList(any(), any()) } returns appOptions
+ viewModel.recentAppsStorage = storage
+
+ viewModel.loadDevicesAndApps(testContext)
+ ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
+
+ verify {
+ connectivityManager.registerNetworkCallback(
+ any(),
+ any<ConnectivityManager.NetworkCallback>(),
+ )
+ }
+
+ assertEquals(1, viewModel.recentAppsList.asFlow().first().size)
+ assertEquals(1, viewModel.appsList.asFlow().first().size)
+ }
+
+ @Test
+ fun `buildAppsList transforms ResolveInfo list`() {
+ assertEquals(emptyList<AppShareOption>(), viewModel.buildAppsList(null, application))
+
+ val icon1: Drawable = mockk()
+ val icon2: Drawable = mockk()
+
+ val info = listOf(
+ createResolveInfo("App 0", icon1, "package 0", "activity 0"),
+ createResolveInfo("Self", mockk(), packageName, "activity self"),
+ createResolveInfo("App 1", icon2, "package 1", "activity 1"),
+ )
+ val apps = listOf(
+ AppShareOption("App 0", icon1, "package 0", "activity 0"),
+ AppShareOption("App 1", icon2, "package 1", "activity 1"),
+ )
+ assertEquals(apps, viewModel.buildAppsList(info, application))
+ }
+
+ @Test
+ fun `buildDevicesList returns offline option`() {
+ every { connectivityManager.isOnline() } returns false
+ assertEquals(listOf(SyncShareOption.Offline), viewModel.buildDeviceList(fxaAccountManager))
+
+ every { connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) } returns null
+ assertEquals(listOf(SyncShareOption.Offline), viewModel.buildDeviceList(fxaAccountManager))
+ }
+
+ @Test
+ fun `buildDevicesList returns sign-in option`() {
+ every { connectivityManager.isOnline() } returns true
+ every { fxaAccountManager.authenticatedAccount() } returns null
+
+ assertEquals(listOf(SyncShareOption.SignIn), viewModel.buildDeviceList(fxaAccountManager))
+ }
+
+ @Test
+ fun `buildDevicesList returns reconnect option`() {
+ every { connectivityManager.isOnline() } returns true
+ every { fxaAccountManager.authenticatedAccount() } returns mockk()
+ every { fxaAccountManager.accountNeedsReauth() } returns true
+
+ assertEquals(
+ listOf(SyncShareOption.Reconnect),
+ viewModel.buildDeviceList(fxaAccountManager),
+ )
+ }
+
+ @Test
+ fun `GIVEN only one app THEN show copy to clipboard before the app`() = runTestOnMain {
+ val appOptions = listOf(
+ AppShareOption("Label", mockk(), "Package", "Activity"),
+ )
+
+ val appEntity = mockk<RecentApp>()
+ every { appEntity.activityName } returns "Activity"
+ every { storage.updateDatabaseWithNewApps(appOptions.map { app -> app.packageName }) } just Runs
+ every { storage.getRecentAppsUpTo(RECENT_APPS_LIMIT) } returns emptyList()
+
+ every { viewModel.buildAppsList(any(), any()) } returns appOptions
+ viewModel.recentAppsStorage = storage
+
+ viewModel.loadDevicesAndApps(testContext)
+ ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
+
+ assertEquals(0, viewModel.recentAppsList.asFlow().first().size)
+ assertEquals(2, viewModel.appsList.asFlow().first().size)
+ assertEquals(ACTION_COPY_LINK_TO_CLIPBOARD, viewModel.appsList.asFlow().first()[0].packageName)
+ }
+
+ @Test
+ fun `WHEN no app THEN at least have copy to clipboard as app`() = runTestOnMain {
+ val appEntity = mockk<RecentApp>()
+ every { appEntity.activityName } returns "Activity"
+ every { storage.getRecentAppsUpTo(RECENT_APPS_LIMIT) } returns emptyList()
+
+ every { viewModel.buildAppsList(any(), any()) } returns emptyList()
+ viewModel.recentAppsStorage = storage
+
+ viewModel.loadDevicesAndApps(testContext)
+ ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
+
+ assertEquals(0, viewModel.recentAppsList.asFlow().first().size)
+ assertEquals(1, viewModel.appsList.asFlow().first().size)
+ assertEquals(ACTION_COPY_LINK_TO_CLIPBOARD, viewModel.appsList.asFlow().first()[0].packageName)
+ }
+
+ private fun createResolveInfo(
+ label: String,
+ icon: Drawable,
+ packageName: String,
+ name: String,
+ ): ResolveInfo {
+ val info = ResolveInfo().apply {
+ activityInfo = ActivityInfo()
+ activityInfo.packageName = packageName
+ activityInfo.name = name
+ }
+ val spy = spyk(info)
+ every { spy.loadLabel(packageManager) } returns label
+ every { spy.loadIcon(packageManager) } returns icon
+ return spy
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/listadapters/AccountDevicesShareAdapterTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/listadapters/AccountDevicesShareAdapterTest.kt
new file mode 100644
index 0000000000..010192c424
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/listadapters/AccountDevicesShareAdapterTest.kt
@@ -0,0 +1,72 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.share.listadapters
+
+import android.view.ViewGroup
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.share.ShareInteractor
+import org.mozilla.fenix.share.viewholders.AccountDeviceViewHolder
+
+@RunWith(FenixRobolectricTestRunner::class)
+class AccountDevicesShareAdapterTest {
+ private val interactor: ShareInteractor = mockk(relaxed = true)
+
+ @Test
+ fun `getItemCount on a default instantiated Adapter should return 0`() {
+ val adapter = AccountDevicesShareAdapter(mockk())
+
+ assertEquals(0, adapter.itemCount)
+ }
+
+ @Test
+ fun `the adapter uses the right ViewHolder`() {
+ val adapter = AccountDevicesShareAdapter(interactor)
+ val parentView: ViewGroup = mockk(relaxed = true)
+ every { parentView.context } returns testContext
+
+ val viewHolder = adapter.onCreateViewHolder(parentView, 0)
+
+ assertEquals(AccountDeviceViewHolder::class, viewHolder::class)
+ }
+
+ @Test
+ fun `the adapter passes the Interactor to the ViewHolder`() {
+ val adapter = AccountDevicesShareAdapter(interactor)
+ val parentView: ViewGroup = mockk(relaxed = true)
+ every { parentView.context } returns testContext
+
+ val viewHolder = adapter.onCreateViewHolder(parentView, 0)
+
+ assertEquals(interactor, viewHolder.interactor)
+ }
+
+ @Test
+ fun `the adapter binds the right item to a ViewHolder`() {
+ val syncOptions = listOf(SyncShareOption.AddNewDevice, SyncShareOption.SignIn)
+ val adapter = AccountDevicesShareAdapter(interactor)
+ adapter.submitList(syncOptions)
+ val parentView: ViewGroup = mockk(relaxed = true)
+ val itemView: ViewGroup = mockk(relaxed = true)
+ every { parentView.context } returns testContext
+ every { itemView.context } returns testContext
+ val viewHolder = spyk(AccountDeviceViewHolder(parentView, mockk()))
+ every { adapter.onCreateViewHolder(parentView, 0) } returns viewHolder
+ every { viewHolder.bind(any()) } just Runs
+
+ adapter.bindViewHolder(viewHolder, 1)
+
+ verify { viewHolder.bind(syncOptions[1]) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/listadapters/AppShareAdapterTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/listadapters/AppShareAdapterTest.kt
new file mode 100644
index 0000000000..be765b1bc3
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/listadapters/AppShareAdapterTest.kt
@@ -0,0 +1,98 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.share.listadapters
+
+import android.view.ViewGroup
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import io.mockk.verifyOrder
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.share.ShareInteractor
+import org.mozilla.fenix.share.viewholders.AppViewHolder
+
+@RunWith(FenixRobolectricTestRunner::class)
+class AppShareAdapterTest {
+
+ private val appOptions = mutableListOf(
+ AppShareOption("App 0", mockk(), "package 0", "activity 0"),
+ AppShareOption("App 1", mockk(), "package 1", "activity 1"),
+ )
+ private val appOptionsEmpty = emptyList<AppShareOption>()
+ private val interactor: ShareInteractor = mockk(relaxed = true)
+
+ @Test
+ fun `updateData should call submitList()`() {
+ // Used AppShareAdapter as a spy to ease testing of submitList()
+ // and appOptionsEmpty to be able to record them being called
+ val adapter = spyk(AppShareAdapter(mockk()).apply { submitList(appOptionsEmpty) })
+ every { adapter.submitList(any()) } just Runs
+
+ adapter.submitList(appOptions)
+
+ verifyOrder {
+ adapter.submitList(appOptions)
+ }
+ }
+
+ @Test
+ fun `getItemCount on a default instantiated Adapter should return 0`() {
+ val adapter = AppShareAdapter(mockk())
+
+ assertEquals(0, adapter.itemCount)
+ }
+
+ @Test
+ fun `getItemCount after updateData() call should return the the passed in list's size`() {
+ val adapter = AppShareAdapter(mockk()).apply { submitList(appOptions) }
+
+ assertEquals(2, adapter.itemCount)
+ }
+
+ @Test
+ fun `the adapter uses the right ViewHolder`() {
+ val adapter = AppShareAdapter(interactor)
+ val parentView: ViewGroup = mockk(relaxed = true)
+ every { parentView.context } returns testContext
+
+ val viewHolder = adapter.onCreateViewHolder(parentView, 0)
+
+ assertEquals(AppViewHolder::class, viewHolder::class)
+ }
+
+ @Test
+ fun `the adapter passes the Interactor to the ViewHolder`() {
+ val adapter = AppShareAdapter(interactor)
+ val parentView: ViewGroup = mockk(relaxed = true)
+ every { parentView.context } returns testContext
+
+ val viewHolder = adapter.onCreateViewHolder(parentView, 0)
+
+ assertEquals(interactor, viewHolder.interactor)
+ }
+
+ @Test
+ fun `the adapter binds the right item to a ViewHolder`() {
+ val adapter = AppShareAdapter(interactor).apply { submitList(appOptions) }
+ val parentView: ViewGroup = mockk(relaxed = true)
+ val itemView: ViewGroup = mockk(relaxed = true)
+ every { parentView.context } returns testContext
+ every { itemView.context } returns testContext
+ val viewHolder = spyk(AppViewHolder(parentView, mockk()))
+ every { adapter.onCreateViewHolder(parentView, 0) } returns viewHolder
+ every { viewHolder.bind(any()) } just Runs
+
+ adapter.bindViewHolder(viewHolder, 1)
+
+ verify { viewHolder.bind(appOptions[1]) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/viewholders/AccountDeviceViewHolderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/viewholders/AccountDeviceViewHolderTest.kt
new file mode 100644
index 0000000000..fa58b6d085
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/viewholders/AccountDeviceViewHolderTest.kt
@@ -0,0 +1,127 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.share.viewholders
+
+import android.view.LayoutInflater
+import io.mockk.Called
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.concept.sync.Device
+import mozilla.components.concept.sync.DeviceType
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.databinding.AccountShareListItemBinding
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.share.ShareToAccountDevicesInteractor
+import org.mozilla.fenix.share.listadapters.SyncShareOption
+
+@RunWith(FenixRobolectricTestRunner::class)
+class AccountDeviceViewHolderTest {
+
+ private val baseDevice = Device(
+ id = "",
+ displayName = "",
+ deviceType = DeviceType.UNKNOWN,
+ isCurrentDevice = true,
+ lastAccessTime = 0L,
+ capabilities = emptyList(),
+ subscriptionExpired = false,
+ subscription = null,
+ )
+ private lateinit var binding: AccountShareListItemBinding
+ private lateinit var viewHolder: AccountDeviceViewHolder
+ private lateinit var interactor: ShareToAccountDevicesInteractor
+
+ @Before
+ fun setup() {
+ interactor = mockk(relaxUnitFun = true)
+
+ binding = AccountShareListItemBinding.inflate(LayoutInflater.from(testContext))
+ viewHolder = AccountDeviceViewHolder(binding.root, interactor)
+ }
+
+ @Test
+ fun `bind SignIn option`() {
+ viewHolder.bind(SyncShareOption.SignIn)
+ assertEquals("Sign in to Sync", binding.deviceName.text)
+
+ viewHolder.itemView.performClick()
+ verify { interactor.onSignIn() }
+ assertFalse(viewHolder.itemView.hasOnClickListeners())
+ }
+
+ @Test
+ fun `bind Reconnect option`() {
+ viewHolder.bind(SyncShareOption.Reconnect)
+ assertEquals("Reconnect to Sync", binding.deviceName.text)
+
+ viewHolder.itemView.performClick()
+ verify { interactor.onReauth() }
+ assertFalse(viewHolder.itemView.hasOnClickListeners())
+ }
+
+ @Test
+ fun `bind Offline option`() {
+ viewHolder.bind(SyncShareOption.Offline)
+ assertEquals("Offline", binding.deviceName.text)
+
+ viewHolder.itemView.performClick()
+ verify { interactor wasNot Called }
+ assertFalse(viewHolder.itemView.hasOnClickListeners())
+ }
+
+ @Test
+ fun `bind AddNewDevice option`() {
+ viewHolder.bind(SyncShareOption.AddNewDevice)
+ assertEquals("Connect another device", binding.deviceName.text)
+
+ viewHolder.itemView.performClick()
+ verify { interactor.onAddNewDevice() }
+ assertFalse(viewHolder.itemView.hasOnClickListeners())
+ }
+
+ @Test
+ fun `bind SendAll option`() {
+ val devices = listOf<Device>(mockk())
+ viewHolder.bind(SyncShareOption.SendAll(devices))
+ assertEquals("Send to all devices", binding.deviceName.text)
+
+ viewHolder.itemView.performClick()
+ verify { interactor.onShareToAllDevices(devices) }
+ assertFalse(viewHolder.itemView.hasOnClickListeners())
+ }
+
+ @Test
+ fun `bind mobile SingleDevice option`() {
+ val device = baseDevice.copy(
+ deviceType = DeviceType.MOBILE,
+ displayName = "Mobile",
+ )
+ viewHolder.bind(SyncShareOption.SingleDevice(device))
+ assertEquals("Mobile", binding.deviceName.text)
+
+ viewHolder.itemView.performClick()
+ verify { interactor.onShareToDevice(device) }
+ assertFalse(viewHolder.itemView.hasOnClickListeners())
+ }
+
+ @Test
+ fun `bind desktop SingleDevice option`() {
+ val device = baseDevice.copy(
+ deviceType = DeviceType.DESKTOP,
+ displayName = "Desktop",
+ )
+ viewHolder.bind(SyncShareOption.SingleDevice(device))
+ assertEquals("Desktop", binding.deviceName.text)
+
+ viewHolder.itemView.performClick()
+ verify { interactor.onShareToDevice(device) }
+ assertFalse(viewHolder.itemView.hasOnClickListeners())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/viewholders/AppViewHolderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/viewholders/AppViewHolderTest.kt
new file mode 100644
index 0000000000..9045409db5
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/share/viewholders/AppViewHolderTest.kt
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.share.viewholders
+
+import android.view.LayoutInflater
+import androidx.appcompat.content.res.AppCompatResources.getDrawable
+import io.mockk.Called
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.databinding.AppShareListItemBinding
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.share.ShareToAppsInteractor
+import org.mozilla.fenix.share.listadapters.AppShareOption
+
+@RunWith(FenixRobolectricTestRunner::class)
+class AppViewHolderTest {
+
+ private lateinit var binding: AppShareListItemBinding
+ private lateinit var viewHolder: AppViewHolder
+ private lateinit var interactor: ShareToAppsInteractor
+
+ @Before
+ fun setup() {
+ interactor = mockk(relaxUnitFun = true)
+
+ binding = AppShareListItemBinding.inflate(LayoutInflater.from(testContext))
+ viewHolder = AppViewHolder(binding.root, interactor)
+ }
+
+ @Test
+ fun `bind app share option`() {
+ val app = AppShareOption(
+ name = "Pocket",
+ icon = getDrawable(testContext, R.drawable.ic_pocket)!!,
+ packageName = "com.mozilla.pocket",
+ activityName = "MainActivity",
+ )
+ viewHolder.bind(app)
+
+ assertEquals("Pocket", binding.appName.text)
+ assertEquals(app.icon, binding.appIcon.drawable)
+ }
+
+ @Test
+ fun `trigger interactor if application is bound`() {
+ val app = AppShareOption(
+ name = "Pocket",
+ icon = getDrawable(testContext, R.drawable.ic_pocket)!!,
+ packageName = "com.mozilla.pocket",
+ activityName = "MainActivity",
+ )
+
+ viewHolder.itemView.performClick()
+ verify { interactor wasNot Called }
+
+ viewHolder.bind(app)
+ viewHolder.itemView.performClick()
+ verify { interactor.onShareToApp(app) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/ProductAnalysisTestData.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/ProductAnalysisTestData.kt
new file mode 100644
index 0000000000..2d2b469680
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/ProductAnalysisTestData.kt
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.shopping
+
+import mozilla.components.concept.engine.shopping.Highlight
+import mozilla.components.concept.engine.shopping.ProductAnalysis
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent.AnalysisStatus
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent.HighlightsInfo
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.RecommendedProductState
+
+object ProductAnalysisTestData {
+
+ fun productAnalysis(
+ productId: String? = "1",
+ analysisURL: String = "https://test.com",
+ grade: String? = "A",
+ adjustedRating: Double? = 4.5,
+ needsAnalysis: Boolean = false,
+ pageNotSupported: Boolean = false,
+ notEnoughReviews: Boolean = false,
+ lastAnalysisTime: Long = 0L,
+ deletedProductReported: Boolean = false,
+ deletedProduct: Boolean = false,
+ highlights: Highlight? = null,
+ ): ProductAnalysis = ProductAnalysis(
+ productId = productId,
+ analysisURL = analysisURL,
+ grade = grade,
+ adjustedRating = adjustedRating,
+ needsAnalysis = needsAnalysis,
+ pageNotSupported = pageNotSupported,
+ notEnoughReviews = notEnoughReviews,
+ lastAnalysisTime = lastAnalysisTime,
+ deletedProductReported = deletedProductReported,
+ deletedProduct = deletedProduct,
+ highlights = highlights,
+ )
+
+ fun analysisPresent(
+ productId: String = "1",
+ productUrl: String = "https://test.com",
+ reviewGrade: ReviewQualityCheckState.Grade? = ReviewQualityCheckState.Grade.A,
+ adjustedRating: Float? = 4.5f,
+ analysisStatus: AnalysisStatus = AnalysisStatus.UpToDate,
+ highlightsInfo: HighlightsInfo? = null,
+ recommendedProductState: RecommendedProductState = RecommendedProductState.Initial,
+ ): ProductReviewState.AnalysisPresent =
+ ProductReviewState.AnalysisPresent(
+ productId = productId,
+ productUrl = productUrl,
+ reviewGrade = reviewGrade,
+ adjustedRating = adjustedRating,
+ analysisStatus = analysisStatus,
+ highlightsInfo = highlightsInfo,
+ recommendedProductState = recommendedProductState,
+ )
+
+ fun noAnalysisPresent(
+ progress: Float = -1f,
+ ): ProductReviewState.NoAnalysisPresent =
+ ProductReviewState.NoAnalysisPresent(
+ progress = ProductReviewState.Progress(progress),
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/ProductRecommendationTestData.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/ProductRecommendationTestData.kt
new file mode 100644
index 0000000000..b2e3160585
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/ProductRecommendationTestData.kt
@@ -0,0 +1,58 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.shopping
+
+import mozilla.components.concept.engine.shopping.ProductRecommendation
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
+
+object ProductRecommendationTestData {
+
+ fun productRecommendation(
+ aid: String = "aid",
+ url: String = "https://test.com",
+ grade: String = "A",
+ adjustedRating: Double = 4.7,
+ sponsored: Boolean = true,
+ analysisUrl: String = "analysisUrl",
+ imageUrl: String = "https://imageurl.com",
+ name: String = "Test Product",
+ price: String = "100",
+ currency: String = "USD",
+ ): ProductRecommendation = ProductRecommendation(
+ aid = aid,
+ url = url,
+ grade = grade,
+ adjustedRating = adjustedRating,
+ sponsored = sponsored,
+ analysisUrl = analysisUrl,
+ imageUrl = imageUrl,
+ name = name,
+ price = price,
+ currency = currency,
+ )
+
+ fun product(
+ aid: String = "aid",
+ url: String = "https://test.com",
+ reviewGrade: ReviewQualityCheckState.Grade = ReviewQualityCheckState.Grade.A,
+ adjustedRating: Double = 4.7,
+ sponsored: Boolean = true,
+ analysisUrl: String = "analysisUrl",
+ imageUrl: String = "https://imageurl.com",
+ name: String = "Test Product",
+ formattedPrice: String = "$100",
+ ): ReviewQualityCheckState.RecommendedProductState.Product =
+ ReviewQualityCheckState.RecommendedProductState.Product(
+ aid = aid,
+ productUrl = url,
+ reviewGrade = reviewGrade,
+ adjustedRating = adjustedRating.toFloat(),
+ isSponsored = sponsored,
+ analysisUrl = analysisUrl,
+ imageUrl = imageUrl,
+ name = name,
+ formattedPrice = formattedPrice,
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/ReviewQualityCheckBottomSheetStateFeatureTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/ReviewQualityCheckBottomSheetStateFeatureTest.kt
new file mode 100644
index 0000000000..564a6483d0
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/ReviewQualityCheckBottomSheetStateFeatureTest.kt
@@ -0,0 +1,95 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.shopping
+
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.shopping.store.BottomSheetViewState
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckStore
+
+class ReviewQualityCheckBottomSheetStateFeatureTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Test
+ fun `WHEN store state changes to not opted in from any other state THEN callback is invoked with half state`() {
+ val store = ReviewQualityCheckStore(middleware = emptyList())
+ var updatedState: BottomSheetViewState? = null
+ val tested = ReviewQualityCheckBottomSheetStateFeature(
+ store = store,
+ onRequestStateUpdate = {
+ updatedState = it
+ },
+ isScreenReaderEnabled = false,
+ )
+
+ tested.start()
+ store.dispatch(
+ ReviewQualityCheckAction.OptInCompleted(
+ isProductRecommendationsEnabled = true,
+ productRecommendationsExposure = true,
+ productVendor = ReviewQualityCheckState.ProductVendor.WALMART,
+ isHighlightsExpanded = false,
+ isInfoExpanded = false,
+ isSettingsExpanded = false,
+ ),
+ ).joinBlocking()
+ store.dispatch(ReviewQualityCheckAction.OptOutCompleted(emptyList())).joinBlocking()
+
+ assertEquals(BottomSheetViewState.HALF_VIEW, updatedState)
+ }
+
+ @Test
+ fun `WHEN store state changes to not opted in from initial state THEN callback is invoked with full state`() {
+ val store = ReviewQualityCheckStore(middleware = emptyList())
+ var updatedState: BottomSheetViewState? = null
+ val tested = ReviewQualityCheckBottomSheetStateFeature(
+ store = store,
+ onRequestStateUpdate = {
+ updatedState = it
+ },
+ isScreenReaderEnabled = false,
+ )
+ assertEquals(ReviewQualityCheckState.Initial, store.state)
+
+ tested.start()
+ store.dispatch(ReviewQualityCheckAction.OptOutCompleted(emptyList())).joinBlocking()
+
+ assertEquals(BottomSheetViewState.FULL_VIEW, updatedState)
+ }
+
+ @Test
+ fun `GIVEN an accessibility screen reader is enabled WHEN user opens bottom sheet THEN it is opened fully`() {
+ val store = ReviewQualityCheckStore(middleware = emptyList())
+ var updatedState: BottomSheetViewState? = null
+ val tested = ReviewQualityCheckBottomSheetStateFeature(
+ store = store,
+ onRequestStateUpdate = {
+ updatedState = it
+ },
+ isScreenReaderEnabled = true,
+ )
+
+ tested.start()
+ store.dispatch(
+ ReviewQualityCheckAction.OptInCompleted(
+ isProductRecommendationsEnabled = true,
+ productRecommendationsExposure = true,
+ productVendor = ReviewQualityCheckState.ProductVendor.WALMART,
+ isHighlightsExpanded = false,
+ isInfoExpanded = false,
+ isSettingsExpanded = false,
+ ),
+ ).joinBlocking()
+
+ assertEquals(BottomSheetViewState.FULL_VIEW, updatedState)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/ReviewQualityCheckFeatureTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/ReviewQualityCheckFeatureTest.kt
new file mode 100644
index 0000000000..de34b236b2
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/ReviewQualityCheckFeatureTest.kt
@@ -0,0 +1,557 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.shopping
+
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppAction.ShoppingAction
+import org.mozilla.fenix.components.appstate.AppState
+import org.mozilla.fenix.components.appstate.shopping.ShoppingState
+import org.mozilla.fenix.shopping.fake.FakeShoppingExperienceFeature
+
+class ReviewQualityCheckFeatureTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Test
+ fun `WHEN feature is not enabled THEN callback returns false`() = runTest {
+ var availability: Boolean? = null
+ val tab = createTab(
+ url = "https://www.mozilla.org",
+ id = "test-tab",
+ isProductUrl = true,
+ )
+ val browserState = BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ )
+ val tested = ReviewQualityCheckFeature(
+ appStore = AppStore(),
+ browserStore = BrowserStore(
+ initialState = browserState,
+ ),
+ shoppingExperienceFeature = FakeShoppingExperienceFeature(enabled = false),
+ onIconVisibilityChange = {
+ availability = it
+ },
+ onBottomSheetStateChange = {},
+ onProductPageDetected = {},
+ )
+
+ tested.start()
+
+ testScheduler.advanceTimeBy(250)
+
+ assertFalse(availability!!)
+ }
+
+ @Test
+ fun `WHEN feature is enabled and selected tab is not a product page THEN callback returns false`() =
+ runTest {
+ var availability: Boolean? = null
+ val tab = createTab(
+ url = "https://www.mozilla.org",
+ id = "test-tab",
+ isProductUrl = false,
+ )
+ val browserState = BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ )
+ val tested = ReviewQualityCheckFeature(
+ appStore = AppStore(),
+ browserStore = BrowserStore(
+ initialState = browserState,
+ ),
+ shoppingExperienceFeature = FakeShoppingExperienceFeature(),
+ onIconVisibilityChange = {
+ availability = it
+ },
+ onBottomSheetStateChange = {},
+ onProductPageDetected = {},
+ )
+
+ tested.start()
+
+ assertFalse(availability!!)
+ }
+
+ @Test
+ fun `WHEN feature is enabled and selected tab is not yet loaded THEN callback returns false`() =
+ runTest {
+ var availability: Boolean? = null
+ val tab = createTab(
+ url = "https://www.mozilla.org",
+ id = "test-tab",
+ isProductUrl = true,
+ ).let {
+ it.copy(content = it.content.copy(loading = true))
+ }
+
+ val browserState = BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ )
+ val tested = ReviewQualityCheckFeature(
+ appStore = AppStore(),
+ browserStore = BrowserStore(
+ initialState = browserState,
+ ),
+ shoppingExperienceFeature = FakeShoppingExperienceFeature(),
+ onIconVisibilityChange = {
+ availability = it
+ },
+ onBottomSheetStateChange = {},
+ onProductPageDetected = {},
+ )
+
+ tested.start()
+
+ assertFalse(availability!!)
+ }
+
+ @Test
+ fun `WHEN feature is enabled and selected tab is a product page THEN callback returns true`() =
+ runTest {
+ var availability: Boolean? = null
+ val tab = createTab(
+ url = "https://www.mozilla.org",
+ id = "test-tab",
+ isProductUrl = true,
+ )
+ val browserState = BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ )
+ val tested = ReviewQualityCheckFeature(
+ appStore = AppStore(),
+ browserStore = BrowserStore(
+ initialState = browserState,
+ ),
+ shoppingExperienceFeature = FakeShoppingExperienceFeature(),
+ onIconVisibilityChange = {
+ availability = it
+ },
+ onBottomSheetStateChange = {},
+ debounceTimeoutMillis = { 0 },
+ onProductPageDetected = {},
+ )
+
+ tested.start()
+
+ assertTrue(availability!!)
+ }
+
+ @Test
+ fun `WHEN feature is enabled and selected tab is switched to a product page THEN callback returns true`() =
+ runTest {
+ var availability: Boolean? = null
+ val tab1 = createTab(
+ url = "https://www.mozilla.org",
+ id = "tab1",
+ isProductUrl = false,
+ )
+ val tab2 = createTab(
+ url = "https://www.shopping.org",
+ id = "tab2",
+ isProductUrl = true,
+ )
+ val browserStore = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(tab1, tab2),
+ selectedTabId = tab1.id,
+ ),
+ )
+ val tested = ReviewQualityCheckFeature(
+ appStore = AppStore(),
+ browserStore = browserStore,
+ shoppingExperienceFeature = FakeShoppingExperienceFeature(),
+ onIconVisibilityChange = {
+ availability = it
+ },
+ onBottomSheetStateChange = {},
+ debounceTimeoutMillis = { 0 },
+ onProductPageDetected = {},
+ )
+
+ tested.start()
+ assertFalse(availability!!)
+
+ browserStore.dispatch(TabListAction.SelectTabAction(tab2.id)).joinBlocking()
+
+ assertTrue(availability!!)
+ }
+
+ @Test
+ fun `WHEN feature is enabled and selected tab is switched to not a product page THEN callback returns false`() =
+ runTest {
+ var availability: Boolean? = null
+ val tab1 = createTab(
+ url = "https://www.shopping.org",
+ id = "tab1",
+ isProductUrl = true,
+ )
+ val tab2 = createTab(
+ url = "https://www.mozilla.org",
+ id = "tab2",
+ isProductUrl = false,
+ )
+ val browserStore = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(tab1, tab2),
+ selectedTabId = tab1.id,
+ ),
+ )
+ val tested = ReviewQualityCheckFeature(
+ appStore = AppStore(),
+ browserStore = browserStore,
+ shoppingExperienceFeature = FakeShoppingExperienceFeature(),
+ onIconVisibilityChange = {
+ availability = it
+ },
+ onBottomSheetStateChange = {},
+ debounceTimeoutMillis = { 0 },
+ onProductPageDetected = {},
+ )
+
+ tested.start()
+ assertTrue(availability!!)
+
+ browserStore.dispatch(TabListAction.SelectTabAction(tab2.id)).joinBlocking()
+
+ assertFalse(availability!!)
+ }
+
+ @Test
+ fun `WHEN feature is enabled and isProductUrl updates a lot THEN callback is only invoked when isProductUrl settles`() =
+ runTest {
+ val availability = mutableListOf<Boolean>()
+ val tab1 = createTab(
+ url = "https://www.shopping.org",
+ id = "tab1",
+ isProductUrl = false,
+ )
+ val browserStore = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(tab1),
+ selectedTabId = tab1.id,
+ ),
+ )
+ val tested = ReviewQualityCheckFeature(
+ appStore = AppStore(),
+ browserStore = browserStore,
+ shoppingExperienceFeature = FakeShoppingExperienceFeature(),
+ onIconVisibilityChange = {
+ availability.add(it)
+ },
+ onBottomSheetStateChange = {},
+ onProductPageDetected = {},
+ )
+
+ tested.start()
+ assertEquals(listOf(false), availability)
+
+ browserStore.dispatch(
+ ContentAction.UpdateProductUrlStateAction(
+ tabId = tab1.id,
+ isProductUrl = true,
+ ),
+ ).joinBlocking()
+
+ browserStore.dispatch(
+ ContentAction.UpdateProductUrlStateAction(
+ tabId = tab1.id,
+ isProductUrl = false,
+ ),
+ ).joinBlocking()
+
+ browserStore.dispatch(
+ ContentAction.UpdateProductUrlStateAction(
+ tabId = tab1.id,
+ isProductUrl = true,
+ ),
+ ).joinBlocking()
+
+ testScheduler.advanceTimeBy(250)
+
+ // The first true is never emitted because it is debounced
+ assertNotEquals(listOf(false, true, false, true), availability)
+ assertEquals(listOf(false, false, true), availability)
+ }
+
+ @Test
+ fun `WHEN feature is enabled and selected tab is switched to a product page after stop is called THEN callback is only called once with false`() =
+ runTest {
+ var availability: Boolean? = null
+ var availabilityCount = 0
+ val tab1 = createTab(
+ url = "https://www.mozilla.org",
+ id = "tab1",
+ isProductUrl = false,
+ )
+ val tab2 = createTab(
+ url = "https://www.shopping.org",
+ id = "tab2",
+ isProductUrl = true,
+ )
+ val browserStore = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(tab1, tab2),
+ selectedTabId = tab1.id,
+ ),
+ )
+
+ val tested = ReviewQualityCheckFeature(
+ appStore = AppStore(),
+ browserStore = browserStore,
+ shoppingExperienceFeature = FakeShoppingExperienceFeature(),
+ onIconVisibilityChange = {
+ availability = it
+ availabilityCount++
+ },
+ onBottomSheetStateChange = {},
+ onProductPageDetected = {},
+ )
+
+ tested.start()
+
+ tested.stop()
+ browserStore.dispatch(TabListAction.SelectTabAction(tab2.id)).joinBlocking()
+
+ assertEquals(1, availabilityCount)
+ assertFalse(availability!!)
+ }
+
+ @Test
+ fun `WHEN the shopping sheet is collapsed THEN the callback is called with false`() {
+ val appStore = AppStore(
+ initialState = AppState(
+ shoppingState = ShoppingState(shoppingSheetExpanded = true),
+ ),
+ )
+ var isExpanded: Boolean? = null
+ val tested = ReviewQualityCheckFeature(
+ appStore = appStore,
+ browserStore = BrowserStore(),
+ shoppingExperienceFeature = FakeShoppingExperienceFeature(),
+ onIconVisibilityChange = {},
+ onBottomSheetStateChange = {
+ isExpanded = it
+ },
+ onProductPageDetected = {},
+ )
+
+ tested.start()
+
+ appStore.dispatch(ShoppingAction.ShoppingSheetStateUpdated(expanded = false)).joinBlocking()
+
+ assertFalse(isExpanded!!)
+ }
+
+ @Test
+ fun `WHEN the shopping sheet is expanded THEN the collapsed callback is called with true`() {
+ val appStore = AppStore(
+ initialState = AppState(
+ shoppingState = ShoppingState(shoppingSheetExpanded = false),
+ ),
+ )
+ var isExpanded: Boolean? = null
+ val tested = ReviewQualityCheckFeature(
+ appStore = appStore,
+ browserStore = BrowserStore(),
+ shoppingExperienceFeature = FakeShoppingExperienceFeature(),
+ onIconVisibilityChange = {},
+ onBottomSheetStateChange = {
+ isExpanded = it
+ },
+ onProductPageDetected = {},
+ )
+
+ tested.start()
+
+ appStore.dispatch(ShoppingAction.ShoppingSheetStateUpdated(expanded = true)).joinBlocking()
+
+ assertTrue(isExpanded!!)
+ }
+
+ @Test
+ fun `WHEN the feature is restarted THEN first emission is collected to set the tint`() {
+ val appStore = AppStore(
+ initialState = AppState(
+ shoppingState = ShoppingState(shoppingSheetExpanded = false),
+ ),
+ )
+ var isExpanded: Boolean? = null
+ val tested = ReviewQualityCheckFeature(
+ appStore = appStore,
+ browserStore = BrowserStore(),
+ shoppingExperienceFeature = FakeShoppingExperienceFeature(),
+ onIconVisibilityChange = {},
+ onBottomSheetStateChange = {
+ isExpanded = it
+ },
+ onProductPageDetected = {},
+ )
+
+ tested.start()
+ tested.stop()
+
+ // emulate emission
+ appStore.dispatch(ShoppingAction.ShoppingSheetStateUpdated(expanded = false)).joinBlocking()
+
+ tested.start()
+ assertFalse(isExpanded!!)
+ }
+
+ @Test
+ fun `GIVEN feature is enabled WHEN non product url accessed THEN callback not called`() {
+ runTest {
+ var invokedCounter = 0
+ val tab = createTab(
+ url = "https://www.mozilla.org",
+ id = "test-tab",
+ isProductUrl = false,
+ )
+ val browserState = BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ )
+ val tested = ReviewQualityCheckFeature(
+ appStore = AppStore(),
+ browserStore = BrowserStore(
+ initialState = browserState,
+ ),
+ shoppingExperienceFeature = FakeShoppingExperienceFeature(),
+ onIconVisibilityChange = {},
+ onBottomSheetStateChange = {},
+ debounceTimeoutMillis = { 0 },
+ onProductPageDetected = {
+ invokedCounter++
+ },
+ )
+
+ tested.start()
+
+ assertEquals(invokedCounter, 0)
+ }
+ }
+
+ @Test
+ fun `GIVEN feature is enabled WHEN product url accessed THEN callback called`() {
+ runTest {
+ var invokedCounter = 0
+ val tab = createTab(
+ url = "https://www.shopping.org",
+ id = "test-tab",
+ isProductUrl = true,
+ ).let {
+ it.copy(content = it.content.copy(loading = false))
+ }
+ val browserState = BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ )
+ val tested = ReviewQualityCheckFeature(
+ appStore = AppStore(),
+ browserStore = BrowserStore(
+ initialState = browserState,
+ ),
+ shoppingExperienceFeature = FakeShoppingExperienceFeature(),
+ onIconVisibilityChange = {},
+ onBottomSheetStateChange = {},
+ debounceTimeoutMillis = { 0 },
+ onProductPageDetected = {
+ invokedCounter++
+ },
+ )
+
+ tested.start()
+
+ assertEquals(invokedCounter, 1)
+ }
+ }
+
+ @Test
+ fun `GIVEN feature is disabled WHEN non product url accessed THEN callback not called`() {
+ runTest {
+ var invokedCounter = 0
+ val tab = createTab(
+ url = "https://www.mozilla.org",
+ id = "test-tab",
+ isProductUrl = false,
+ )
+ val browserState = BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ )
+ val tested = ReviewQualityCheckFeature(
+ appStore = AppStore(),
+ browserStore = BrowserStore(
+ initialState = browserState,
+ ),
+ shoppingExperienceFeature = FakeShoppingExperienceFeature(enabled = false),
+ onIconVisibilityChange = {},
+ onBottomSheetStateChange = {},
+ debounceTimeoutMillis = { 0 },
+ onProductPageDetected = {
+ invokedCounter++
+ },
+ )
+
+ tested.start()
+
+ assertEquals(invokedCounter, 0)
+ }
+ }
+
+ @Test
+ fun `GIVEN feature is disabled WHEN product url accessed THEN callback called`() {
+ runTest {
+ var invokedCounter = 0
+ val tab = createTab(
+ url = "https://www.mozilla.org",
+ id = "test-tab",
+ isProductUrl = true,
+ ).let {
+ it.copy(content = it.content.copy(loading = false))
+ }
+ val browserState = BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ )
+ val tested = ReviewQualityCheckFeature(
+ appStore = AppStore(),
+ browserStore = BrowserStore(
+ initialState = browserState,
+ ),
+ shoppingExperienceFeature = FakeShoppingExperienceFeature(enabled = false),
+ onIconVisibilityChange = {},
+ onBottomSheetStateChange = {},
+ debounceTimeoutMillis = { 0 },
+ onProductPageDetected = {
+ invokedCounter++
+ },
+ )
+
+ tested.start()
+
+ assertEquals(invokedCounter, 1)
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeNetworkChecker.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeNetworkChecker.kt
new file mode 100644
index 0000000000..7f5d739e16
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeNetworkChecker.kt
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.shopping.fake
+
+import org.mozilla.fenix.shopping.middleware.NetworkChecker
+
+class FakeNetworkChecker(
+ private val isConnected: Boolean = true,
+) : NetworkChecker {
+ override fun isConnected(): Boolean = isConnected
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeReviewQualityCheckPreferences.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeReviewQualityCheckPreferences.kt
new file mode 100644
index 0000000000..9e2a22f87a
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeReviewQualityCheckPreferences.kt
@@ -0,0 +1,27 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.shopping.fake
+
+import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckPreferences
+
+class FakeReviewQualityCheckPreferences(
+ private val isEnabled: Boolean = false,
+ private val isProductRecommendationsEnabled: Boolean? = false,
+ private val updateCFRCallback: () -> Unit = { },
+) : ReviewQualityCheckPreferences {
+ override suspend fun enabled(): Boolean = isEnabled
+
+ override suspend fun productRecommendationsEnabled(): Boolean? = isProductRecommendationsEnabled
+
+ override suspend fun setEnabled(isEnabled: Boolean) {
+ }
+
+ override suspend fun setProductRecommendationsEnabled(isEnabled: Boolean) {
+ }
+
+ override suspend fun updateCFRCondition(time: Long) {
+ updateCFRCallback()
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeReviewQualityCheckService.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeReviewQualityCheckService.kt
new file mode 100644
index 0000000000..e049f88fb1
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeReviewQualityCheckService.kt
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.shopping.fake
+
+import mozilla.components.concept.engine.shopping.ProductAnalysis
+import mozilla.components.concept.engine.shopping.ProductRecommendation
+import org.mozilla.fenix.shopping.middleware.AnalysisStatusDto
+import org.mozilla.fenix.shopping.middleware.AnalysisStatusProgressDto
+import org.mozilla.fenix.shopping.middleware.ReportBackInStockStatusDto
+import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckService
+
+class FakeReviewQualityCheckService(
+ private val productAnalysis: (Int) -> ProductAnalysis? = { null },
+ private val reanalysis: AnalysisStatusDto? = null,
+ private val statusProgress: () -> AnalysisStatusProgressDto? = { null },
+ private val productRecommendation: () -> ProductRecommendation? = { null },
+ private val report: ReportBackInStockStatusDto? = null,
+) : ReviewQualityCheckService {
+
+ private var analysisCount = 0
+
+ override suspend fun fetchProductReview(): ProductAnalysis? {
+ return productAnalysis(analysisCount).also {
+ analysisCount++
+ }
+ }
+
+ override suspend fun reanalyzeProduct(): AnalysisStatusDto? = reanalysis
+
+ override suspend fun analysisStatus(): AnalysisStatusProgressDto? {
+ return statusProgress.invoke()
+ }
+
+ override suspend fun productRecommendation(shouldRecordAvailableTelemetry: Boolean): ProductRecommendation? {
+ return productRecommendation.invoke()
+ }
+
+ override suspend fun reportBackInStock(): ReportBackInStockStatusDto? = report
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeReviewQualityCheckTelemetryService.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeReviewQualityCheckTelemetryService.kt
new file mode 100644
index 0000000000..07a377f3bd
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeReviewQualityCheckTelemetryService.kt
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.shopping.fake
+
+import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckTelemetryService
+
+class FakeReviewQualityCheckTelemetryService(
+ private val recordClick: (String) -> Unit = {},
+ private val recordImpression: (String) -> Unit = {},
+) : ReviewQualityCheckTelemetryService {
+
+ override suspend fun recordRecommendedProductClick(productAid: String) {
+ return recordClick.invoke(productAid)
+ }
+
+ override suspend fun recordRecommendedProductImpression(productAid: String) {
+ return recordImpression.invoke(productAid)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeReviewQualityCheckVendorsService.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeReviewQualityCheckVendorsService.kt
new file mode 100644
index 0000000000..13fb78b364
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeReviewQualityCheckVendorsService.kt
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.shopping.fake
+
+import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckVendorsService
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.ProductVendor
+
+class FakeReviewQualityCheckVendorsService(
+ private val selectedTabUrl: String? = null,
+ private val productVendors: List<ProductVendor> = listOf(
+ ProductVendor.BEST_BUY,
+ ProductVendor.WALMART,
+ ProductVendor.AMAZON,
+ ),
+) : ReviewQualityCheckVendorsService {
+ override fun selectedTabUrl(): String? = selectedTabUrl
+
+ override fun productVendors(): List<ProductVendor> = productVendors
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeShoppingExperienceFeature.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeShoppingExperienceFeature.kt
new file mode 100644
index 0000000000..572b18ea3b
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeShoppingExperienceFeature.kt
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.shopping.fake
+
+import org.mozilla.fenix.shopping.ShoppingExperienceFeature
+
+class FakeShoppingExperienceFeature(
+ private val enabled: Boolean = true,
+ private val productRecommendationsExposureEnabled: Boolean = true,
+) : ShoppingExperienceFeature {
+
+ override val isEnabled: Boolean
+ get() = enabled
+
+ override val isProductRecommendationsExposureEnabled: Boolean
+ get() = productRecommendationsExposureEnabled
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/DefaultReviewQualityCheckServiceTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/DefaultReviewQualityCheckServiceTest.kt
new file mode 100644
index 0000000000..f774988291
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/DefaultReviewQualityCheckServiceTest.kt
@@ -0,0 +1,313 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.shopping.middleware
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.concept.engine.shopping.ProductAnalysis
+import mozilla.components.concept.engine.shopping.ProductRecommendation
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.Shopping
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.shopping.ProductAnalysisTestData
+import org.mozilla.fenix.shopping.ProductRecommendationTestData
+
+@RunWith(FenixRobolectricTestRunner::class)
+class DefaultReviewQualityCheckServiceTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @Test
+ fun `GIVEN fetch is called WHEN onResult is invoked with the expected type THEN product analysis returns the same data`() =
+ runTest {
+ val engineSession = mockk<EngineSession>()
+ val expected = ProductAnalysisTestData.productAnalysis()
+
+ every {
+ engineSession.requestProductAnalysis(any(), any(), any())
+ }.answers {
+ secondArg<(ProductAnalysis) -> Unit>().invoke(expected)
+ }
+
+ val tab = createTab(
+ url = "https://www.shopping.org/product",
+ id = "test-tab",
+ engineSession = engineSession,
+ )
+ val browserState = BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ )
+
+ val tested = DefaultReviewQualityCheckService(BrowserStore(browserState))
+
+ val actual = tested.fetchProductReview()
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `GIVEN fetch is called WHEN onException is invoked THEN product analysis returns null`() =
+ runTest {
+ val engineSession = mockk<EngineSession>()
+
+ every {
+ engineSession.requestProductAnalysis(any(), any(), any())
+ }.answers {
+ thirdArg<(Throwable) -> Unit>().invoke(RuntimeException())
+ }
+
+ val tab = createTab(
+ url = "https://www.shopping.org/product",
+ id = "test-tab",
+ engineSession = engineSession,
+ )
+ val browserState = BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ )
+
+ val tested = DefaultReviewQualityCheckService(BrowserStore(browserState))
+
+ assertNull(tested.fetchProductReview())
+ }
+
+ @Test
+ fun `WHEN fetch is called THEN fetch is called for the selected tab`() = runTest {
+ val engineSession = mockk<EngineSession>()
+ val expected = ProductAnalysisTestData.productAnalysis()
+
+ every {
+ engineSession.requestProductAnalysis(any(), any(), any())
+ }.answers {
+ secondArg<(ProductAnalysis) -> Unit>().invoke(expected)
+ }
+
+ val tab1 = createTab(
+ url = "https://www.mozilla.org",
+ id = "1",
+ )
+ val tab2 = createTab(
+ url = "https://www.shopping.org/product",
+ id = "2",
+ engineSession = engineSession,
+ )
+ val browserState = BrowserState(
+ tabs = listOf(tab1, tab2),
+ selectedTabId = tab2.id,
+ )
+
+ val tested = DefaultReviewQualityCheckService(BrowserStore(browserState))
+
+ val actual = tested.fetchProductReview()
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `GIVEN product recommendations is called WHEN onResult is invoked with the result THEN recommendations returns the data and exposure is called`() =
+ runTest {
+ val engineSession = mockk<EngineSession>()
+ val expected = ProductRecommendationTestData.productRecommendation()
+ val productRecommendations = listOf(expected)
+
+ every {
+ engineSession.requestProductRecommendations(any(), any(), any())
+ }.answers {
+ secondArg<(List<ProductRecommendation>) -> Unit>().invoke(productRecommendations)
+ }
+
+ val tab = createTab(
+ url = "https://www.shopping.org/product",
+ id = "test-tab",
+ engineSession = engineSession,
+ )
+ val browserState = BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ )
+
+ val tested = DefaultReviewQualityCheckService(BrowserStore(browserState))
+
+ val actual = tested.productRecommendation(false)
+
+ assertEquals(expected, actual)
+ assertNotNull(Shopping.adsExposure.testGetValue())
+ }
+
+ @Test
+ fun `GIVEN product recommendations is called WHEN onResult is invoked with a empty list and telemetry should be recorded THEN recommendations returns null and no ads available event is called`() =
+ runTest {
+ val engineSession = mockk<EngineSession>()
+
+ every {
+ engineSession.requestProductRecommendations(any(), any(), any())
+ }.answers {
+ secondArg<(List<ProductRecommendation>) -> Unit>().invoke(emptyList())
+ }
+
+ val tab = createTab(
+ url = "https://www.shopping.org/product",
+ id = "test-tab",
+ engineSession = engineSession,
+ )
+ val browserState = BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ )
+
+ val tested = DefaultReviewQualityCheckService(BrowserStore(browserState))
+
+ val actual = tested.productRecommendation(true)
+
+ assertNull(actual)
+ assertNotNull(Shopping.surfaceNoAdsAvailable.testGetValue())
+ }
+
+ @Test
+ fun `GIVEN product recommendations is called WHEN onResult is invoked with a empty list and telemetry should not be recorded THEN recommendations returns null and no ads available event is not called`() =
+ runTest {
+ val engineSession = mockk<EngineSession>()
+
+ every {
+ engineSession.requestProductRecommendations(any(), any(), any())
+ }.answers {
+ secondArg<(List<ProductRecommendation>) -> Unit>().invoke(emptyList())
+ }
+
+ val tab = createTab(
+ url = "https://www.shopping.org/product",
+ id = "test-tab",
+ engineSession = engineSession,
+ )
+ val browserState = BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ )
+
+ val tested = DefaultReviewQualityCheckService(BrowserStore(browserState))
+
+ val actual = tested.productRecommendation(false)
+
+ assertNull(actual)
+ assertNull(Shopping.surfaceNoAdsAvailable.testGetValue())
+ }
+
+ @Test
+ fun `GIVEN product recommendations is called WHEN onException is invoked THEN recommendations returns null`() =
+ runTest {
+ val engineSession = mockk<EngineSession>()
+
+ every {
+ engineSession.requestProductRecommendations(any(), any(), any())
+ }.answers {
+ thirdArg<(Throwable) -> Unit>().invoke(RuntimeException())
+ }
+
+ val tab = createTab(
+ url = "https://www.shopping.org/product",
+ id = "test-tab",
+ engineSession = engineSession,
+ )
+ val browserState = BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ )
+
+ val tested = DefaultReviewQualityCheckService(BrowserStore(browserState))
+
+ val actual = tested.productRecommendation(false)
+
+ assertNull(actual)
+ }
+
+ @Test
+ fun `GIVEN product recommendations is called WHEN onResult is invoked with the result THEN recommendations returns the same result without re-fetching again`() =
+ runTest {
+ val engineSession = mockk<EngineSession>()
+ val expected = ProductRecommendationTestData.productRecommendation()
+ val productRecommendations = listOf(expected)
+
+ every {
+ engineSession.requestProductRecommendations(any(), any(), any())
+ }.answers {
+ secondArg<(List<ProductRecommendation>) -> Unit>().invoke(productRecommendations)
+ }
+
+ val tab = createTab(
+ url = "https://www.shopping.org/product",
+ id = "test-tab",
+ engineSession = engineSession,
+ )
+ val browserState = BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ )
+
+ val tested = DefaultReviewQualityCheckService(BrowserStore(browserState))
+
+ tested.productRecommendation(false)
+ tested.productRecommendation(false)
+ val actual = tested.productRecommendation(false)
+
+ assertEquals(expected, actual)
+
+ verify(exactly = 1) {
+ engineSession.requestProductRecommendations(any(), any(), any())
+ }
+ }
+
+ @Test
+ fun `GIVEN product recommendations is called WHEN onResult is invoked with the empty result THEN recommendations fetches every time`() =
+ runTest {
+ val engineSession = mockk<EngineSession>()
+
+ every {
+ engineSession.requestProductRecommendations(any(), any(), any())
+ }.answers {
+ secondArg<(List<ProductRecommendation>) -> Unit>().invoke(emptyList())
+ }
+
+ val tab = createTab(
+ url = "https://www.shopping.org/product",
+ id = "test-tab",
+ engineSession = engineSession,
+ )
+ val browserState = BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ )
+
+ val tested = DefaultReviewQualityCheckService(BrowserStore(browserState))
+
+ tested.productRecommendation(false)
+ tested.productRecommendation(false)
+ val actual = tested.productRecommendation(false)
+
+ assertNull(actual)
+
+ verify(exactly = 3) {
+ engineSession.requestProductRecommendations(any(), any(), any())
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/DefaultReviewQualityCheckVendorsServiceTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/DefaultReviewQualityCheckVendorsServiceTest.kt
new file mode 100644
index 0000000000..b7a045a305
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/DefaultReviewQualityCheckVendorsServiceTest.kt
@@ -0,0 +1,204 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.shopping.middleware
+
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.ProductVendor
+
+class DefaultReviewQualityCheckVendorsServiceTest {
+
+ @Test
+ fun `WHEN selected tab is an amazon_com page THEN amazon is first in product vendors list`() =
+ runTest {
+ val tab = createTab(
+ url = "https://www.amazon.com/product",
+ id = "test-tab",
+ )
+ val browserState = BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ )
+
+ val tested = DefaultReviewQualityCheckVendorsService(BrowserStore(browserState))
+
+ val actual = tested.productVendors()
+ val expected = listOf(
+ ProductVendor.AMAZON,
+ ProductVendor.BEST_BUY,
+ ProductVendor.WALMART,
+ )
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN selected tab is a walmart page THEN walmart is first in product vendors list`() =
+ runTest {
+ val tab = createTab(
+ url = "https://www.walmart.com/product",
+ id = "test-tab",
+ )
+
+ val browserState = BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ )
+
+ val tested = DefaultReviewQualityCheckVendorsService(BrowserStore(browserState))
+
+ val actual = tested.productVendors()
+ val expected = listOf(
+ ProductVendor.WALMART,
+ ProductVendor.AMAZON,
+ ProductVendor.BEST_BUY,
+ )
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN selected tab is a best buy page THEN best buy is first in product vendors list`() =
+ runTest {
+ val tab = createTab(
+ url = "https://www.bestbuy.com/product",
+ id = "test-tab",
+ )
+
+ val browserState = BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ )
+
+ val tested = DefaultReviewQualityCheckVendorsService(BrowserStore(browserState))
+
+ val actual = tested.productVendors()
+ val expected = listOf(
+ ProductVendor.BEST_BUY,
+ ProductVendor.AMAZON,
+ ProductVendor.WALMART,
+ )
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN selected tab is a not a vendor page THEN default product vendors list is returned`() =
+ runTest {
+ val tab = createTab(
+ url = "https://www.shopping.xyz/product",
+ id = "test-tab",
+ )
+
+ val browserState = BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ )
+
+ val tested = DefaultReviewQualityCheckVendorsService(BrowserStore(browserState))
+
+ val actual = tested.productVendors()
+ val expected = listOf(
+ ProductVendor.AMAZON,
+ ProductVendor.BEST_BUY,
+ ProductVendor.WALMART,
+ )
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN selected tab is a not a valid uri THEN default product vendors list is returned`() =
+ runTest {
+ val tab = createTab(
+ url = "not a url",
+ id = "test-tab",
+ )
+
+ val browserState = BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ )
+
+ val tested = DefaultReviewQualityCheckVendorsService(BrowserStore(browserState))
+
+ val actual = tested.productVendors()
+ val expected = listOf(
+ ProductVendor.AMAZON,
+ ProductVendor.BEST_BUY,
+ ProductVendor.WALMART,
+ )
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN selected tab is an amazon_de page THEN amazon is first in product vendors list`() =
+ runTest {
+ val tab = createTab(
+ url = "https://www.amazon.de/product",
+ id = "test-tab",
+ )
+ val browserState = BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ )
+
+ val tested = DefaultReviewQualityCheckVendorsService(BrowserStore(browserState))
+
+ val actual = tested.productVendors()
+ val expected = listOf(ProductVendor.AMAZON)
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN selected tab is an amazon_fr page THEN amazon is first in product vendors list`() =
+ runTest {
+ val tab = createTab(
+ url = "https://www.amazon.fr/product",
+ id = "test-tab",
+ )
+ val browserState = BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ )
+
+ val tested = DefaultReviewQualityCheckVendorsService(BrowserStore(browserState))
+
+ val actual = tested.productVendors()
+ val expected = listOf(ProductVendor.AMAZON)
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN selected tab is an amazon_in page THEN default product vendors list is returned`() =
+ runTest {
+ val tab = createTab(
+ url = "https://www.amazon.in/product",
+ id = "test-tab",
+ )
+ val browserState = BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tab.id,
+ )
+
+ val tested = DefaultReviewQualityCheckVendorsService(BrowserStore(browserState))
+
+ val actual = tested.productVendors()
+ val expected = listOf(
+ ProductVendor.AMAZON,
+ ProductVendor.BEST_BUY,
+ ProductVendor.WALMART,
+ )
+
+ assertEquals(expected, actual)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/EnumMapperTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/EnumMapperTest.kt
new file mode 100644
index 0000000000..df8dc93002
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/EnumMapperTest.kt
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.shopping.middleware
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class EnumMapperTest {
+
+ private enum class DeviceType {
+ PHONE,
+ TABLET,
+ WEARABLE,
+ OTHER_TYPE,
+ }
+
+ @Test
+ fun `GIVEN an enum WHEN a string is an enum object THEN it is mapped to the enum`() {
+ val phone = "phone"
+ val tablet = "TABLET"
+ val wearable = "WEARABLE"
+ val other = "other_type"
+ val otherWithSpace = "other type"
+
+ assertEquals(DeviceType.PHONE, phone.asEnumOrDefault<DeviceType>())
+ assertEquals(DeviceType.TABLET, tablet.asEnumOrDefault<DeviceType>())
+ assertEquals(DeviceType.WEARABLE, wearable.asEnumOrDefault<DeviceType>())
+ assertEquals(DeviceType.OTHER_TYPE, other.asEnumOrDefault<DeviceType>())
+ assertEquals(DeviceType.OTHER_TYPE, otherWithSpace.asEnumOrDefault<DeviceType>())
+ }
+
+ @Test
+ fun `GIVEN an enum WHEN a string is not an enum object and not default is passed THEN null is returned`() {
+ val input = "car"
+
+ assertNull(input.asEnumOrDefault<DeviceType>())
+ }
+
+ @Test
+ fun `GIVEN an enum WHEN a string is not an enum object and default is passed THEN default is returned`() {
+ val input = "car"
+
+ assertEquals(DeviceType.OTHER_TYPE, input.asEnumOrDefault(DeviceType.OTHER_TYPE))
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/ProductAnalysisMapperTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/ProductAnalysisMapperTest.kt
new file mode 100644
index 0000000000..4c33bd9f7b
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/ProductAnalysisMapperTest.kt
@@ -0,0 +1,361 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.shopping.middleware
+
+import mozilla.components.concept.engine.shopping.Highlight
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.mozilla.fenix.shopping.ProductAnalysisTestData
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.HighlightType
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent.AnalysisStatus
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent.HighlightsInfo
+
+class ProductAnalysisMapperTest {
+
+ @Test
+ fun `WHEN ProductAnalysis has data THEN it is mapped to AnalysisPresent`() {
+ val actual = ProductAnalysisTestData.productAnalysis(
+ productId = "id1",
+ grade = "C",
+ needsAnalysis = false,
+ adjustedRating = 3.4,
+ analysisURL = "https://example.com",
+ highlights = Highlight(
+ quality = listOf("quality"),
+ price = listOf("price"),
+ shipping = listOf("shipping"),
+ appearance = listOf("appearance"),
+ competitiveness = listOf("competitiveness"),
+ ),
+ ).toProductReviewState()
+
+ val expected = ProductAnalysisTestData.analysisPresent(
+ productId = "id1",
+ reviewGrade = ReviewQualityCheckState.Grade.C,
+ analysisStatus = AnalysisStatus.UpToDate,
+ adjustedRating = 3.4f,
+ productUrl = "https://example.com",
+ highlightsInfo = HighlightsInfo(
+ mapOf(
+ HighlightType.QUALITY to listOf("quality"),
+ HighlightType.PRICE to listOf("price"),
+ HighlightType.SHIPPING to listOf("shipping"),
+ HighlightType.PACKAGING_AND_APPEARANCE to listOf("appearance"),
+ HighlightType.COMPETITIVENESS to listOf("competitiveness"),
+ ),
+ ),
+ )
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN ProductAnalysis has data with some missing highlights THEN it is mapped to AnalysisPresent with the non null highlights`() {
+ val actual = ProductAnalysisTestData.productAnalysis(
+ productId = "id1",
+ grade = "C",
+ needsAnalysis = true,
+ adjustedRating = 3.4,
+ analysisURL = "https://example.com",
+ highlights = Highlight(
+ quality = listOf("quality"),
+ price = null,
+ shipping = null,
+ appearance = listOf("appearance"),
+ competitiveness = listOf("competitiveness"),
+ ),
+ ).toProductReviewState()
+
+ val expected = ProductAnalysisTestData.analysisPresent(
+ productId = "id1",
+ reviewGrade = ReviewQualityCheckState.Grade.C,
+ analysisStatus = AnalysisStatus.NeedsAnalysis,
+ adjustedRating = 3.4f,
+ productUrl = "https://example.com",
+ highlightsInfo = HighlightsInfo(
+ mapOf(
+ HighlightType.QUALITY to listOf("quality"),
+ HighlightType.PACKAGING_AND_APPEARANCE to listOf("appearance"),
+ HighlightType.COMPETITIVENESS to listOf("competitiveness"),
+ ),
+ ),
+ )
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN ProductAnalysis has an invalid grade THEN it is mapped to AnalysisPresent with grade as null`() {
+ val actual = ProductAnalysisTestData.productAnalysis(
+ productId = "id1",
+ grade = "?",
+ needsAnalysis = false,
+ adjustedRating = 3.4,
+ analysisURL = "https://example.com",
+ ).toProductReviewState()
+
+ val expected = ProductAnalysisTestData.analysisPresent(
+ productId = "id1",
+ reviewGrade = null,
+ analysisStatus = AnalysisStatus.UpToDate,
+ adjustedRating = 3.4f,
+ productUrl = "https://example.com",
+ )
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN product analysis is null THEN it is mapped to Error`() {
+ val actual = null.toProductReviewState()
+ val expected = ReviewQualityCheckState.OptedIn.ProductReviewState.Error.GenericError
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN product id is null and needs analysis is true THEN it is mapped to no analysis present`() {
+ val actual =
+ ProductAnalysisTestData.productAnalysis(
+ productId = null,
+ needsAnalysis = true,
+ ).toProductReviewState()
+ val expected = ReviewQualityCheckState.OptedIn.ProductReviewState.NoAnalysisPresent()
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN product id is null and needs analysis is false THEN it is mapped to no generic error`() {
+ val actual =
+ ProductAnalysisTestData.productAnalysis(
+ productId = null,
+ needsAnalysis = false,
+ ).toProductReviewState()
+ val expected = ReviewQualityCheckState.OptedIn.ProductReviewState.Error.GenericError
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN there are not enough reviews and no analysis needed THEN not enough reviews card is visible`() {
+ val actual = ProductAnalysisTestData.productAnalysis(
+ notEnoughReviews = true,
+ needsAnalysis = false,
+ ).toProductReviewState()
+
+ val expected = ReviewQualityCheckState.OptedIn.ProductReviewState.Error.NotEnoughReviews
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN there are enough reviews and no analysis needed THEN it is mapped to AnalysisPresent`() {
+ val actual = ProductAnalysisTestData.productAnalysis(
+ notEnoughReviews = false,
+ needsAnalysis = false,
+ ).toProductReviewState()
+
+ val expected = ProductAnalysisTestData.analysisPresent(
+ productId = "1",
+ reviewGrade = ReviewQualityCheckState.Grade.A,
+ analysisStatus = AnalysisStatus.UpToDate,
+ adjustedRating = 4.5f,
+ productUrl = "https://test.com",
+ )
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN there are not enough reviews and analysis is needed THEN it is mapped to AnalysisPresent with NEEDS_ANALYSIS status`() {
+ val actual = ProductAnalysisTestData.productAnalysis(
+ notEnoughReviews = true,
+ needsAnalysis = true,
+ ).toProductReviewState()
+
+ val expected = ProductAnalysisTestData.analysisPresent(
+ productId = "1",
+ reviewGrade = ReviewQualityCheckState.Grade.A,
+ analysisStatus = AnalysisStatus.NeedsAnalysis,
+ adjustedRating = 4.5f,
+ productUrl = "https://test.com",
+ highlightsInfo = null,
+ )
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN grade, rating and highlights are all null THEN it is mapped to no analysis present`() {
+ val actual =
+ ProductAnalysisTestData.productAnalysis(
+ grade = null,
+ adjustedRating = null,
+ highlights = null,
+ ).toProductReviewState()
+ val expected = ReviewQualityCheckState.OptedIn.ProductReviewState.NoAnalysisPresent()
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN only rating is available THEN it is mapped to AnalysisPresent`() {
+ val actual = ProductAnalysisTestData.productAnalysis(
+ grade = null,
+ adjustedRating = 3.5,
+ highlights = null,
+ ).toProductReviewState()
+
+ val expected = ProductAnalysisTestData.analysisPresent(
+ reviewGrade = null,
+ adjustedRating = 3.5f,
+ highlightsInfo = null,
+ )
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN only grade is available THEN it is mapped to AnalysisPresent`() {
+ val actual = ProductAnalysisTestData.productAnalysis(
+ grade = "B",
+ adjustedRating = null,
+ highlights = null,
+ ).toProductReviewState()
+
+ val expected = ProductAnalysisTestData.analysisPresent(
+ reviewGrade = ReviewQualityCheckState.Grade.B,
+ adjustedRating = null,
+ highlightsInfo = null,
+ )
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN only highlights are available THEN it is mapped to AnalysisPresent`() {
+ val actual = ProductAnalysisTestData.productAnalysis(
+ grade = null,
+ adjustedRating = null,
+ highlights = Highlight(
+ quality = listOf("quality"),
+ price = null,
+ shipping = null,
+ appearance = listOf("appearance"),
+ competitiveness = listOf("competitiveness"),
+ ),
+ ).toProductReviewState()
+
+ val expected = ProductAnalysisTestData.analysisPresent(
+ reviewGrade = null,
+ adjustedRating = null,
+ highlightsInfo = HighlightsInfo(
+ mapOf(
+ HighlightType.QUALITY to listOf("quality"),
+ HighlightType.PACKAGING_AND_APPEARANCE to listOf("appearance"),
+ HighlightType.COMPETITIVENESS to listOf("competitiveness"),
+ ),
+ ),
+ )
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN highlights and grade are available THEN it is mapped to AnalysisPresent`() {
+ val actual = ProductAnalysisTestData.productAnalysis(
+ grade = "B",
+ adjustedRating = null,
+ highlights = Highlight(
+ quality = listOf("quality"),
+ price = null,
+ shipping = null,
+ appearance = listOf("appearance"),
+ competitiveness = listOf("competitiveness"),
+ ),
+ ).toProductReviewState()
+
+ val expected = ProductAnalysisTestData.analysisPresent(
+ reviewGrade = ReviewQualityCheckState.Grade.B,
+ adjustedRating = null,
+ highlightsInfo = HighlightsInfo(
+ mapOf(
+ HighlightType.QUALITY to listOf("quality"),
+ HighlightType.PACKAGING_AND_APPEARANCE to listOf("appearance"),
+ HighlightType.COMPETITIVENESS to listOf("competitiveness"),
+ ),
+ ),
+ )
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN highlights and rating are available THEN it is mapped to AnalysisPresent`() {
+ val actual = ProductAnalysisTestData.productAnalysis(
+ grade = null,
+ adjustedRating = 3.4,
+ highlights = Highlight(
+ quality = listOf("quality"),
+ price = null,
+ shipping = null,
+ appearance = listOf("appearance"),
+ competitiveness = listOf("competitiveness"),
+ ),
+ ).toProductReviewState()
+
+ val expected = ProductAnalysisTestData.analysisPresent(
+ reviewGrade = null,
+ adjustedRating = 3.4f,
+ highlightsInfo = HighlightsInfo(
+ mapOf(
+ HighlightType.QUALITY to listOf("quality"),
+ HighlightType.PACKAGING_AND_APPEARANCE to listOf("appearance"),
+ HighlightType.COMPETITIVENESS to listOf("competitiveness"),
+ ),
+ ),
+ )
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN page not supported is true THEN it is mapped to unsupported product error `() {
+ val actual = ProductAnalysisTestData.productAnalysis(
+ pageNotSupported = true,
+ ).toProductReviewState()
+
+ val expected =
+ ReviewQualityCheckState.OptedIn.ProductReviewState.Error.UnsupportedProductTypeError
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN product deleted is true and has not been reported THEN it is mapped to not available and not back in stock`() {
+ val actual = ProductAnalysisTestData.productAnalysis(
+ deletedProduct = true,
+ deletedProductReported = false,
+ ).toProductReviewState()
+
+ val expected =
+ ReviewQualityCheckState.OptedIn.ProductReviewState.Error.ProductNotAvailable
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN product deleted is true and has been reported THEN it is mapped to not available and back in stock`() {
+ val actual = ProductAnalysisTestData.productAnalysis(
+ deletedProduct = true,
+ deletedProductReported = true,
+ ).toProductReviewState()
+
+ val expected =
+ ReviewQualityCheckState.OptedIn.ProductReviewState.Error.ProductAlreadyReported
+ assertEquals(expected, actual)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/ProductRecommendationMapperTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/ProductRecommendationMapperTest.kt
new file mode 100644
index 0000000000..ae0f93f1af
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/ProductRecommendationMapperTest.kt
@@ -0,0 +1,50 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.shopping.middleware
+
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.helpers.LocaleTestRule
+import org.mozilla.fenix.shopping.ProductRecommendationTestData
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
+import java.util.Locale
+
+class ProductRecommendationMapperTest {
+
+ @get:Rule
+ val localeTestRule = LocaleTestRule(Locale.US)
+
+ @Test
+ fun `WHEN ProductRecommendation is null THEN it is mapped to Initial`() {
+ val actual = null.toRecommendedProductState()
+ val expected = ReviewQualityCheckState.RecommendedProductState.Initial
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN ProductRecommendation has data THEN it is mapped to product`() {
+ val productRecommendation = ProductRecommendationTestData.productRecommendation()
+ val actual = productRecommendation.toRecommendedProductState()
+ val expected = ProductRecommendationTestData.product()
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN ProductRecommendation has data with invalid currency code THEN it is mapped to product`() {
+ val productRecommendation = ProductRecommendationTestData.productRecommendation(
+ price = "100",
+ currency = "invalid",
+ )
+ val actual = productRecommendation.toRecommendedProductState()
+ val expected = ProductRecommendationTestData.product(
+ formattedPrice = "100",
+ )
+
+ assertEquals(expected, actual)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/RetryKtTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/RetryKtTest.kt
new file mode 100644
index 0000000000..53fe580262
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/RetryKtTest.kt
@@ -0,0 +1,59 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.shopping.middleware
+
+import junit.framework.TestCase.assertEquals
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class RetryKtTest {
+
+ @Test
+ fun `WHEN predicate is false THEN return data on first attempt`() = runTest {
+ var count = 0
+
+ val actual = retry(predicate = { false }) {
+ count += 1
+ count
+ }
+ val expected = 1
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN predicate is true THEN retry max times`() = runTest {
+ var count = 0
+
+ val actual = retry(
+ maxRetries = 10,
+ predicate = { true },
+ ) {
+ count += 1
+ count
+ }
+
+ val expected = 10
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `WHEN predicate changes to false from true THEN return data on that attempt`() = runTest {
+ var count = 0
+
+ val actual = retry(
+ maxRetries = 10,
+ predicate = { it < 5 },
+ ) {
+ count += 1
+ count
+ }
+
+ val expected = 5
+
+ assertEquals(expected, actual)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckNavigationMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckNavigationMiddlewareTest.kt
new file mode 100644
index 0000000000..f2e98f974d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckNavigationMiddlewareTest.kt
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.shopping.middleware
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.feature.tabs.TabsUseCases
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckStore
+
+class ReviewQualityCheckNavigationMiddlewareTest {
+
+ private val sumoUrl = "https://support.mozilla.org/en-US/products/mobile"
+ private lateinit var store: ReviewQualityCheckStore
+ private lateinit var browserStore: BrowserStore
+ private lateinit var addTabUseCase: TabsUseCases.SelectOrAddUseCase
+ private lateinit var middleware: ReviewQualityCheckNavigationMiddleware
+
+ @Before
+ fun setup() {
+ browserStore = BrowserStore()
+ addTabUseCase = mockk(relaxed = true)
+ middleware = ReviewQualityCheckNavigationMiddleware(
+ selectOrAddUseCase = addTabUseCase,
+ getReviewQualityCheckSumoUrl = mockk {
+ every { this@mockk.invoke() } returns sumoUrl
+ },
+ )
+ store = ReviewQualityCheckStore(
+ middleware = listOf(middleware),
+ )
+ }
+
+ @Test
+ fun `WHEN opening an external link THEN the link should be opened in a new tab`() {
+ val action = ReviewQualityCheckAction.OpenExplainerLearnMoreLink
+ store.waitUntilIdle()
+ assertEquals(0, browserStore.state.tabs.size)
+
+ store.dispatch(action).joinBlocking()
+ store.waitUntilIdle()
+
+ verify {
+ addTabUseCase.invoke(sumoUrl)
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckTelemetryMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckTelemetryMiddlewareTest.kt
new file mode 100644
index 0000000000..2a631516fc
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckTelemetryMiddlewareTest.kt
@@ -0,0 +1,512 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.shopping.middleware
+
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.Shopping
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppState
+import org.mozilla.fenix.components.appstate.shopping.ShoppingState
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.shopping.ProductAnalysisTestData
+import org.mozilla.fenix.shopping.fake.FakeReviewQualityCheckTelemetryService
+import org.mozilla.fenix.shopping.store.BottomSheetDismissSource
+import org.mozilla.fenix.shopping.store.BottomSheetViewState
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckStore
+
+@RunWith(FenixRobolectricTestRunner::class)
+class ReviewQualityCheckTelemetryMiddlewareTest {
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ private lateinit var store: ReviewQualityCheckStore
+
+ @Before
+ fun setup() {
+ store = ReviewQualityCheckStore(
+ middleware = provideTelemetryMiddleware(),
+ )
+ store.waitUntilIdle()
+ }
+
+ @Test
+ fun `WHEN the user opts in the feature THEN the opt in event is recorded`() {
+ store.dispatch(ReviewQualityCheckAction.OptIn).joinBlocking()
+ store.waitUntilIdle()
+
+ assertNotNull(Shopping.surfaceOptInAccepted.testGetValue())
+ }
+
+ @Test
+ fun `WHEN the bottom sheet is closed THEN the bottom sheet closed event is recorded`() {
+ store.dispatch(ReviewQualityCheckAction.BottomSheetClosed(BottomSheetDismissSource.CLICK_OUTSIDE))
+ .joinBlocking()
+ store.waitUntilIdle()
+
+ assertNotNull(Shopping.surfaceClosed.testGetValue())
+ val event = Shopping.surfaceClosed.testGetValue()!!
+ assertEquals(1, event.size)
+ assertEquals(
+ BottomSheetDismissSource.CLICK_OUTSIDE.sourceName,
+ event.single().extra?.getValue("source"),
+ )
+ }
+
+ @Test
+ fun `WHEN the bottom sheet is displayed THEN the bottom sheet displayed event is recorded`() {
+ store.dispatch(ReviewQualityCheckAction.BottomSheetDisplayed(BottomSheetViewState.HALF_VIEW))
+ .joinBlocking()
+ store.waitUntilIdle()
+
+ assertNotNull(Shopping.surfaceDisplayed.testGetValue())
+ val event = Shopping.surfaceDisplayed.testGetValue()!!
+ assertEquals(1, event.size)
+ assertEquals(BottomSheetViewState.HALF_VIEW.state, event.single().extra?.getValue("view"))
+ }
+
+ @Test
+ fun `WHEN the learn more link from the explainer card is clicked THEN the explainer learn more event is recorded`() {
+ store.dispatch(ReviewQualityCheckAction.OpenExplainerLearnMoreLink).joinBlocking()
+ store.waitUntilIdle()
+
+ assertNotNull(Shopping.surfaceReviewQualityExplainerUrlClicked.testGetValue())
+ }
+
+ @Test
+ fun `WHEN the terms and conditions link from the onboarding card is clicked THEN the terms and conditions event is recorded`() {
+ store.dispatch(ReviewQualityCheckAction.OpenOnboardingTermsLink).joinBlocking()
+ store.waitUntilIdle()
+
+ assertNotNull(Shopping.surfaceShowTermsClicked.testGetValue())
+ }
+
+ @Test
+ fun `WHEN the privacy policy link from the onboarding card is clicked THEN the privacy policy event is recorded`() {
+ store.dispatch(ReviewQualityCheckAction.OpenOnboardingPrivacyPolicyLink).joinBlocking()
+ store.waitUntilIdle()
+
+ assertNotNull(Shopping.surfaceShowPrivacyPolicyClicked.testGetValue())
+ }
+
+ @Test
+ fun `WHEN the learn more link from the onboarding card is clicked THEN the onboarding learn more event is recorded`() {
+ store.dispatch(ReviewQualityCheckAction.OpenOnboardingLearnMoreLink).joinBlocking()
+ store.waitUntilIdle()
+
+ assertNotNull(Shopping.surfaceLearnMoreClicked.testGetValue())
+ }
+
+ @Test
+ fun `WHEN the not now button from the onboarding card is clicked THEN the not now event is recorded`() {
+ store.dispatch(ReviewQualityCheckAction.NotNowClicked).joinBlocking()
+ store.waitUntilIdle()
+
+ assertNotNull(Shopping.surfaceNotNowClicked.testGetValue())
+ }
+
+ @Test
+ fun `WHEN the expand button from the highlights card is clicked THEN the show more recent reviews event is recorded`() {
+ val tested = ReviewQualityCheckStore(
+ initialState = ReviewQualityCheckState.OptedIn(
+ productRecommendationsPreference = true,
+ productRecommendationsExposure = true,
+ productVendor = ReviewQualityCheckState.ProductVendor.AMAZON,
+ isHighlightsExpanded = false,
+ ),
+ middleware = provideTelemetryMiddleware(),
+ )
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.ExpandCollapseHighlights).joinBlocking()
+ tested.waitUntilIdle()
+
+ assertNotNull(Shopping.surfaceShowMoreRecentReviewsClicked.testGetValue())
+ }
+
+ @Test
+ fun `WHEN the collapse button from the highlights card is clicked THEN the show more recent reviews event is not recorded`() {
+ val tested = ReviewQualityCheckStore(
+ initialState = ReviewQualityCheckState.OptedIn(
+ productRecommendationsPreference = true,
+ productRecommendationsExposure = true,
+ productVendor = ReviewQualityCheckState.ProductVendor.AMAZON,
+ isHighlightsExpanded = true,
+ ),
+ middleware = provideTelemetryMiddleware(),
+ )
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.ExpandCollapseHighlights).joinBlocking()
+ tested.waitUntilIdle()
+
+ assertNull(Shopping.surfaceShowMoreRecentReviewsClicked.testGetValue())
+ }
+
+ @Test
+ fun `WHEN the expand button from the settings card is clicked THEN the settings expand event is recorded`() {
+ val tested = ReviewQualityCheckStore(
+ initialState = ReviewQualityCheckState.OptedIn(
+ productRecommendationsPreference = true,
+ productRecommendationsExposure = true,
+ productVendor = ReviewQualityCheckState.ProductVendor.AMAZON,
+ isSettingsExpanded = false,
+ ),
+ middleware = provideTelemetryMiddleware(),
+ )
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.ExpandCollapseSettings).joinBlocking()
+ tested.waitUntilIdle()
+
+ assertNotNull(Shopping.surfaceExpandSettings.testGetValue())
+ }
+
+ @Test
+ fun `WHEN the collapse button from the settings card is clicked THEN the settings expand event is not recorded`() {
+ val tested = ReviewQualityCheckStore(
+ initialState = ReviewQualityCheckState.OptedIn(
+ productRecommendationsPreference = true,
+ productRecommendationsExposure = true,
+ productVendor = ReviewQualityCheckState.ProductVendor.AMAZON,
+ isSettingsExpanded = true,
+ ),
+ middleware = provideTelemetryMiddleware(),
+ )
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.ExpandCollapseSettings).joinBlocking()
+ tested.waitUntilIdle()
+
+ assertNull(Shopping.surfaceExpandSettings.testGetValue())
+ }
+
+ @Test
+ fun `WHEN no analysis is present THEN the no analysis event is recorded`() {
+ store.dispatch(ReviewQualityCheckAction.NoAnalysisDisplayed).joinBlocking()
+ store.waitUntilIdle()
+
+ assertNotNull(Shopping.surfaceNoReviewReliabilityAvailable.testGetValue())
+ }
+
+ @Test
+ fun `WHEN analyze button is clicked THEN the analyze reviews event is recorded`() {
+ store.dispatch(ReviewQualityCheckAction.AnalyzeProduct).joinBlocking()
+ store.waitUntilIdle()
+
+ assertNotNull(Shopping.surfaceAnalyzeReviewsNoneAvailableClicked.testGetValue())
+ }
+
+ @Test
+ fun `WHEN reanalyze button is clicked THEN the reanalyze event is recorded`() {
+ store.dispatch(ReviewQualityCheckAction.ReanalyzeProduct).joinBlocking()
+ store.waitUntilIdle()
+
+ assertNotNull(Shopping.surfaceReanalyzeClicked.testGetValue())
+ }
+
+ @Test
+ fun `WHEN back in stock button is clicked THEN the reactivate event is recorded`() {
+ store.dispatch(ReviewQualityCheckAction.ReportProductBackInStock).joinBlocking()
+ store.waitUntilIdle()
+
+ assertNotNull(Shopping.surfaceReactivatedButtonClicked.testGetValue())
+ }
+
+ @Test
+ fun `WHEN the user is opted out after initializing the feature after THEN the onboarding displayed event is recorded`() {
+ store.dispatch(ReviewQualityCheckAction.OptOutCompleted(emptyList())).joinBlocking()
+ store.waitUntilIdle()
+
+ assertNotNull(Shopping.surfaceOnboardingDisplayed.testGetValue())
+ }
+
+ @Test
+ fun `WHEN the user is tapped the 'Powered by Fakespot by Mozilla' link THEN the link clicked telemetry is recorded`() {
+ store.dispatch(ReviewQualityCheckAction.OpenPoweredByLink).joinBlocking()
+ store.waitUntilIdle()
+
+ assertNotNull(Shopping.surfacePoweredByFakespotLinkClicked.testGetValue())
+ }
+
+ @Test
+ fun `GIVEN a product review has been updated WHEN restore analysis is false THEN the stale analysis event is recorded`() {
+ val productReviewState = ProductAnalysisTestData.analysisPresent(
+ analysisStatus = AnalysisPresent.AnalysisStatus.NeedsAnalysis,
+ )
+
+ val tested = ReviewQualityCheckStore(
+ initialState = ReviewQualityCheckState.OptedIn(
+ productRecommendationsPreference = null,
+ productRecommendationsExposure = true,
+ productVendor = ReviewQualityCheckState.ProductVendor.BEST_BUY,
+ ),
+ middleware = provideTelemetryMiddleware(),
+ )
+
+ tested.dispatch(
+ ReviewQualityCheckAction.UpdateProductReview(
+ productReviewState = productReviewState,
+ restoreAnalysis = false,
+ ),
+ ).joinBlocking()
+ tested.waitUntilIdle()
+
+ assertNotNull(Shopping.surfaceStaleAnalysisShown.testGetValue())
+ }
+
+ @Test
+ fun `GIVEN a product review has been updated WHEN restore analysis is true THEN the stale analysis event is not recorded`() {
+ val productReviewState = ProductAnalysisTestData.analysisPresent(
+ analysisStatus = AnalysisPresent.AnalysisStatus.NeedsAnalysis,
+ )
+ val tested = ReviewQualityCheckStore(
+ initialState = ReviewQualityCheckState.OptedIn(
+ productRecommendationsPreference = null,
+ productRecommendationsExposure = true,
+ productVendor = ReviewQualityCheckState.ProductVendor.BEST_BUY,
+ ),
+ middleware = provideTelemetryMiddleware(),
+ )
+
+ tested.dispatch(
+ ReviewQualityCheckAction.UpdateProductReview(
+ productReviewState = productReviewState,
+ restoreAnalysis = true,
+ ),
+ ).joinBlocking()
+ tested.waitUntilIdle()
+
+ assertNull(Shopping.surfaceStaleAnalysisShown.testGetValue())
+ }
+
+ @Test
+ fun `GIVEN a product review has been updated WHEN it is not a stale analysis THEN the stale analysis event is not recorded`() {
+ val productReviewState = ProductAnalysisTestData.analysisPresent()
+
+ val tested = ReviewQualityCheckStore(
+ initialState = ReviewQualityCheckState.OptedIn(
+ productRecommendationsPreference = null,
+ productRecommendationsExposure = true,
+ productVendor = ReviewQualityCheckState.ProductVendor.BEST_BUY,
+ ),
+ middleware = provideTelemetryMiddleware(),
+ )
+
+ tested.dispatch(
+ ReviewQualityCheckAction.UpdateProductReview(
+ productReviewState = productReviewState,
+ restoreAnalysis = true,
+ ),
+ ).joinBlocking()
+ tested.waitUntilIdle()
+
+ assertNull(Shopping.surfaceStaleAnalysisShown.testGetValue())
+ }
+
+ @Test
+ fun `GIVEN a recommendation impression action is dispatched WHEN app state does not contain key with tab id, product url and aid THEN ad impression telemetry probe is sent`() =
+ runTest {
+ var productViewed: String? = null
+ val tested = ReviewQualityCheckStore(
+ middleware = provideTelemetryMiddleware(
+ reviewQualityCheckTelemetryService = FakeReviewQualityCheckTelemetryService(
+ recordImpression = {
+ productViewed = it
+ },
+ ),
+ browserState = BrowserState(
+ selectedTabId = "tabId",
+ tabs = listOf(
+ createTab(
+ id = "tabId",
+ url = "pdp",
+ ),
+ ),
+ ),
+ ),
+ )
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.RecommendedProductImpression("productId"))
+ .joinBlocking()
+ tested.waitUntilIdle()
+
+ assertNotNull(Shopping.surfaceAdsImpression.testGetValue())
+ assertEquals("productId", productViewed)
+ }
+
+ @Test
+ fun `WHEN recommendation impression action is dispatched many times and app state does not initially contain key with tab id, product url and aid THEN ad impression telemetry probe is sent only once`() =
+ runTest {
+ var productViewed: String? = null
+ var impressionCount = 0
+ val appStore = AppStore()
+ val tested = ReviewQualityCheckStore(
+ middleware = provideTelemetryMiddleware(
+ reviewQualityCheckTelemetryService = FakeReviewQualityCheckTelemetryService(
+ recordImpression = {
+ productViewed = it
+ impressionCount++
+ },
+ ),
+ browserState = BrowserState(
+ selectedTabId = "tabId",
+ tabs = listOf(
+ createTab(
+ id = "tabId",
+ url = "pdp",
+ ),
+ ),
+ ),
+ appStore = appStore,
+ ),
+ )
+ tested.waitUntilIdle()
+ for (i in 1..100) {
+ tested.dispatch(ReviewQualityCheckAction.RecommendedProductImpression("productId"))
+ .joinBlocking()
+ tested.waitUntilIdle()
+ appStore.waitUntilIdle()
+ }
+
+ assertNotNull(Shopping.surfaceAdsImpression.testGetValue())
+ assertEquals("productId", productViewed)
+ assertEquals(1, impressionCount)
+ }
+
+ @Test
+ fun `GIVEN a recommendation impression action is dispatched WHEN app state contains key with tab id, product url and aid THEN ad impression telemetry probe is NOT sent`() =
+ runTest {
+ var productViewed: String? = null
+ val tested = ReviewQualityCheckStore(
+ middleware = provideTelemetryMiddleware(
+ reviewQualityCheckTelemetryService = FakeReviewQualityCheckTelemetryService(
+ recordImpression = { productViewed = it },
+ ),
+ browserState = BrowserState(
+ selectedTabId = "tabId",
+ tabs = listOf(
+ createTab(
+ id = "tabId",
+ url = "pdp",
+ ),
+ ),
+ ),
+ appStore = AppStore(
+ AppState(
+ shoppingState = ShoppingState(
+ recordedProductRecommendationImpressions = setOf(
+ ShoppingState.ProductRecommendationImpressionKey(
+ tabId = "tabId",
+ productUrl = "pdp",
+ aid = "productId",
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ )
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.RecommendedProductImpression("productId"))
+ .joinBlocking()
+ tested.waitUntilIdle()
+
+ assertNull(Shopping.surfaceAdsImpression.testGetValue())
+ assertNull(productViewed)
+ }
+
+ @Test
+ fun `WHEN a product recommendation is clicked THEN the ad clicked telemetry probe is sent`() =
+ runTest {
+ var productClicked: String? = null
+ val tested = ReviewQualityCheckStore(
+ middleware = provideTelemetryMiddleware(
+ reviewQualityCheckTelemetryService = FakeReviewQualityCheckTelemetryService(
+ recordClick = { productClicked = it },
+ ),
+ ),
+ )
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.RecommendedProductClick("productId", ""))
+ .joinBlocking()
+ tested.waitUntilIdle()
+
+ assertNotNull(Shopping.surfaceAdsClicked.testGetValue())
+ assertEquals("productId", productClicked)
+ }
+
+ @Test
+ fun `GIVEN the user has opted in WHEN the user switches product recommendations on THEN send enabled product recommendations toggled telemetry probe`() {
+ val tested = ReviewQualityCheckStore(
+ initialState = ReviewQualityCheckState.OptedIn(
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ReviewQualityCheckState.ProductVendor.AMAZON,
+ isHighlightsExpanded = false,
+ ),
+ middleware = provideTelemetryMiddleware(),
+ )
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.ToggleProductRecommendation).joinBlocking()
+ tested.waitUntilIdle()
+
+ assertEquals(
+ "enabled",
+ Shopping.surfaceAdsSettingToggled.testGetValue()!!.first().extra!!["action"],
+ )
+ }
+
+ @Test
+ fun `GIVEN the user has opted in WHEN the user switches product recommendations off THEN send disabled product recommendations toggled telemetry probe`() {
+ val tested = ReviewQualityCheckStore(
+ initialState = ReviewQualityCheckState.OptedIn(
+ productRecommendationsPreference = true,
+ productRecommendationsExposure = true,
+ productVendor = ReviewQualityCheckState.ProductVendor.AMAZON,
+ isHighlightsExpanded = false,
+ ),
+ middleware = provideTelemetryMiddleware(),
+ )
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.ToggleProductRecommendation).joinBlocking()
+ tested.waitUntilIdle()
+
+ assertEquals(
+ "disabled",
+ Shopping.surfaceAdsSettingToggled.testGetValue()!!.first().extra!!["action"],
+ )
+ }
+
+ private fun provideTelemetryMiddleware(
+ reviewQualityCheckTelemetryService: FakeReviewQualityCheckTelemetryService = FakeReviewQualityCheckTelemetryService(),
+ browserState: BrowserState = BrowserState(),
+ appStore: AppStore = AppStore(),
+ ) = listOf(
+ ReviewQualityCheckTelemetryMiddleware(
+ reviewQualityCheckTelemetryService,
+ BrowserStore(browserState),
+ appStore,
+ coroutinesTestRule.scope,
+ ),
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/store/ReviewQualityCheckStateTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/store/ReviewQualityCheckStateTest.kt
new file mode 100644
index 0000000000..708d98dc01
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/store/ReviewQualityCheckStateTest.kt
@@ -0,0 +1,247 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.shopping.store
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.mozilla.fenix.shopping.ProductAnalysisTestData
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent.HighlightsInfo
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.Progress
+
+class ReviewQualityCheckStateTest {
+
+ @Test
+ fun `WHEN highlights are present THEN highlights to display in compact mode should contain first 2 highlights of the first highlight type`() {
+ val highlights = mapOf(
+ ReviewQualityCheckState.HighlightType.QUALITY to listOf(
+ "High quality",
+ "Excellent craftsmanship",
+ "Superior materials",
+ ),
+ ReviewQualityCheckState.HighlightType.PRICE to listOf(
+ "Affordable prices",
+ "Great value for money",
+ "Discounted offers",
+ ),
+ ReviewQualityCheckState.HighlightType.SHIPPING to listOf(
+ "Fast and reliable shipping",
+ "Free shipping options",
+ "Express delivery",
+ ),
+ ReviewQualityCheckState.HighlightType.PACKAGING_AND_APPEARANCE to listOf(
+ "Elegant packaging",
+ "Attractive appearance",
+ "Beautiful design",
+ ),
+ ReviewQualityCheckState.HighlightType.COMPETITIVENESS to listOf(
+ "Competitive pricing",
+ "Strong market presence",
+ "Unbeatable deals",
+ ),
+ )
+ val highlightsInfo = HighlightsInfo(highlights)
+
+ val expected = mapOf(
+ ReviewQualityCheckState.HighlightType.QUALITY to listOf(
+ "High quality",
+ "Excellent craftsmanship",
+ ),
+ )
+
+ assertEquals(expected, highlightsInfo.highlightsForCompactMode)
+ }
+
+ @Test
+ fun `WHEN only 1 highlight is present THEN highlights to display in compact mode should contain that one`() {
+ val highlights = mapOf(
+ ReviewQualityCheckState.HighlightType.PRICE to listOf(
+ "Affordable prices",
+ ),
+ )
+ val highlightsInfo = HighlightsInfo(highlights)
+
+ val expected = mapOf(
+ ReviewQualityCheckState.HighlightType.PRICE to listOf(
+ "Affordable prices",
+ ),
+ )
+
+ assertEquals(expected, highlightsInfo.highlightsForCompactMode)
+ }
+
+ @Test
+ fun `WHEN AnalysisPresent is created with grade, rating and highlights as null THEN exception is thrown`() {
+ assertThrows(IllegalArgumentException::class.java) {
+ ProductAnalysisTestData.analysisPresent(
+ reviewGrade = null,
+ adjustedRating = null,
+ highlightsInfo = null,
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN AnalysisPresent is created with at least one of grade, rating and highlights as not null THEN no exception is thrown`() {
+ val ratingPresent = kotlin.runCatching {
+ ProductAnalysisTestData.analysisPresent(
+ reviewGrade = null,
+ adjustedRating = 1.2f,
+ highlightsInfo = null,
+ )
+ }
+
+ val gradePresent = kotlin.runCatching {
+ ProductAnalysisTestData.analysisPresent(
+ reviewGrade = ReviewQualityCheckState.Grade.A,
+ adjustedRating = null,
+ highlightsInfo = null,
+ )
+ }
+
+ val highlightsPresent = kotlin.runCatching {
+ ProductAnalysisTestData.analysisPresent(
+ reviewGrade = null,
+ adjustedRating = null,
+ highlightsInfo = HighlightsInfo(
+ mapOf(ReviewQualityCheckState.HighlightType.QUALITY to listOf("")),
+ ),
+ )
+ }
+
+ assert(ratingPresent.isSuccess)
+ assert(gradePresent.isSuccess)
+ assert(highlightsPresent.isSuccess)
+ }
+
+ @Test
+ fun `WHEN AnalysisPresent has more than 2 highlights snippets THEN show more button and highlights fade are visible`() {
+ val highlights = mapOf(
+ ReviewQualityCheckState.HighlightType.QUALITY to listOf(
+ "High quality",
+ "Excellent craftsmanship",
+ "Superior materials",
+ ),
+ ReviewQualityCheckState.HighlightType.PRICE to listOf(
+ "Affordable prices",
+ "Great value for money",
+ "Discounted offers",
+ ),
+ ReviewQualityCheckState.HighlightType.SHIPPING to listOf(
+ "Fast and reliable shipping",
+ "Free shipping options",
+ "Express delivery",
+ ),
+ ReviewQualityCheckState.HighlightType.PACKAGING_AND_APPEARANCE to listOf(
+ "Elegant packaging",
+ "Attractive appearance",
+ "Beautiful design",
+ ),
+ ReviewQualityCheckState.HighlightType.COMPETITIVENESS to listOf(
+ "Competitive pricing",
+ "Strong market presence",
+ "Unbeatable deals",
+ ),
+ )
+ val analysis = ProductAnalysisTestData.analysisPresent(
+ highlightsInfo = HighlightsInfo(highlights),
+ )
+
+ assertTrue(analysis.highlightsInfo!!.highlightsFadeVisible)
+ assertTrue(analysis.highlightsInfo!!.showMoreButtonVisible)
+ }
+
+ @Test
+ fun `WHEN AnalysisPresent has exactly 1 highlights snippet THEN show more button and highlights fade are not visible`() {
+ val highlights = mapOf(
+ ReviewQualityCheckState.HighlightType.PRICE to listOf("Affordable prices"),
+ )
+ val analysis = ProductAnalysisTestData.analysisPresent(
+ highlightsInfo = HighlightsInfo(highlights),
+ )
+
+ assertFalse(analysis.highlightsInfo!!.highlightsFadeVisible)
+ assertFalse(analysis.highlightsInfo!!.showMoreButtonVisible)
+ }
+
+ @Test
+ fun `WHEN AnalysisPresent has exactly 2 highlights snippets THEN show more button and highlights fade are not visible`() {
+ val highlights = mapOf(
+ ReviewQualityCheckState.HighlightType.SHIPPING to listOf(
+ "Fast and reliable shipping",
+ "Free shipping options",
+ ),
+ )
+ val analysis = ProductAnalysisTestData.analysisPresent(
+ highlightsInfo = HighlightsInfo(highlights),
+ )
+
+ assertFalse(analysis.highlightsInfo!!.highlightsFadeVisible)
+ assertFalse(analysis.highlightsInfo!!.showMoreButtonVisible)
+ }
+
+ @Test
+ fun `WHEN AnalysisPresent has a single highlights section and the section has more than 2 snippets THEN show more button and highlights fade are visible`() {
+ val highlights = mapOf(
+ ReviewQualityCheckState.HighlightType.SHIPPING to listOf(
+ "Fast and reliable shipping",
+ "Free shipping options",
+ "Express delivery",
+ ),
+ )
+ val analysis = ProductAnalysisTestData.analysisPresent(
+ highlightsInfo = HighlightsInfo(highlights),
+ )
+
+ assertTrue(analysis.highlightsInfo!!.highlightsFadeVisible)
+ assertTrue(analysis.highlightsInfo!!.showMoreButtonVisible)
+ }
+
+ @Test
+ fun `WHEN AnalysisPresent has only 1 highlight snippet for the first category and more for others THEN show more button is visible and highlights fade is not visible`() {
+ val highlights = mapOf(
+ ReviewQualityCheckState.HighlightType.QUALITY to listOf(
+ "High quality",
+ ),
+ ReviewQualityCheckState.HighlightType.PACKAGING_AND_APPEARANCE to listOf(
+ "Elegant packaging",
+ "Attractive appearance",
+ "Beautiful design",
+ ),
+ ReviewQualityCheckState.HighlightType.COMPETITIVENESS to listOf(
+ "Competitive pricing",
+ "Strong market presence",
+ "Unbeatable deals",
+ ),
+ )
+ val analysis = ProductAnalysisTestData.analysisPresent(
+ highlightsInfo = HighlightsInfo(highlights),
+ )
+
+ assertTrue(analysis.highlightsInfo!!.showMoreButtonVisible)
+ assertFalse(analysis.highlightsInfo!!.highlightsFadeVisible)
+ }
+
+ @Test
+ fun `WHEN progress has a positive value THEN normalized progress should match`() {
+ val progress = Progress(61.6f)
+ assertEquals(0.616f, progress.normalizedProgress)
+ }
+
+ @Test
+ fun `WHEN no analysis is present with progress THEN normalized progress should match and progress bar is visible`() {
+ val analysis = ProductAnalysisTestData.noAnalysisPresent(progress = 61.6f)
+ assertTrue(analysis.isProgressBarVisible)
+ assertEquals(0.616f, analysis.progress.normalizedProgress)
+ }
+
+ @Test
+ fun `WHEN no analysis is present with negative progress THEN progress bar is not visible`() {
+ val analysis = ProductAnalysisTestData.noAnalysisPresent(progress = -1f)
+ assertFalse(analysis.isProgressBarVisible)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/store/ReviewQualityCheckStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/store/ReviewQualityCheckStoreTest.kt
new file mode 100644
index 0000000000..bf76625017
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/store/ReviewQualityCheckStoreTest.kt
@@ -0,0 +1,1630 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.shopping.store
+
+import junit.framework.TestCase.assertEquals
+import junit.framework.TestCase.assertTrue
+import kotlinx.coroutines.test.runTest
+import mozilla.components.lib.state.ext.observeForever
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.middleware.CaptureActionsMiddleware
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Assert.assertFalse
+import org.junit.Ignore
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppAction
+import org.mozilla.fenix.components.appstate.AppAction.ShoppingAction.HighlightsCardExpanded
+import org.mozilla.fenix.components.appstate.AppAction.ShoppingAction.InfoCardExpanded
+import org.mozilla.fenix.components.appstate.AppAction.ShoppingAction.SettingsCardExpanded
+import org.mozilla.fenix.components.appstate.AppState
+import org.mozilla.fenix.components.appstate.shopping.ShoppingState
+import org.mozilla.fenix.shopping.ProductAnalysisTestData
+import org.mozilla.fenix.shopping.ProductRecommendationTestData
+import org.mozilla.fenix.shopping.ShoppingExperienceFeature
+import org.mozilla.fenix.shopping.fake.FakeNetworkChecker
+import org.mozilla.fenix.shopping.fake.FakeReviewQualityCheckPreferences
+import org.mozilla.fenix.shopping.fake.FakeReviewQualityCheckService
+import org.mozilla.fenix.shopping.fake.FakeReviewQualityCheckVendorsService
+import org.mozilla.fenix.shopping.fake.FakeShoppingExperienceFeature
+import org.mozilla.fenix.shopping.middleware.AnalysisStatusDto
+import org.mozilla.fenix.shopping.middleware.AnalysisStatusProgressDto
+import org.mozilla.fenix.shopping.middleware.NetworkChecker
+import org.mozilla.fenix.shopping.middleware.ReportBackInStockStatusDto
+import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckNetworkMiddleware
+import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckPreferences
+import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckPreferencesMiddleware
+import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckService
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent.AnalysisStatus
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.Progress
+import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.ProductVendor
+import java.util.Locale
+
+class ReviewQualityCheckStoreTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val dispatcher = coroutinesTestRule.testDispatcher
+ private val scope = coroutinesTestRule.scope
+
+ @Test
+ fun `GIVEN the user has not opted in the feature WHEN store is created THEN state should display not opted in UI`() =
+ runTest {
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
+ isEnabled = false,
+ isProductRecommendationsEnabled = false,
+ ),
+ reviewQualityCheckVendorsService = FakeReviewQualityCheckVendorsService(
+ productVendors = listOf(
+ ProductVendor.BEST_BUY,
+ ProductVendor.AMAZON,
+ ProductVendor.WALMART,
+ ),
+ ),
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.waitUntilIdle()
+
+ val expected = ReviewQualityCheckState.NotOptedIn(
+ productVendors = listOf(
+ ProductVendor.BEST_BUY,
+ ProductVendor.AMAZON,
+ ProductVendor.WALMART,
+ ),
+ )
+ assertEquals(expected, tested.state)
+ }
+
+ @Test
+ fun `GIVEN the user has not opted in the feature WHEN the user opts in THEN state should display opted in UI`() =
+ runTest {
+ var cfrConditionUpdated = false
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
+ isEnabled = false,
+ isProductRecommendationsEnabled = false,
+ updateCFRCallback = { cfrConditionUpdated = true },
+ ),
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.OptIn).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+ assertEquals(expected, tested.state)
+ assertEquals(true, cfrConditionUpdated)
+ }
+
+ @Test
+ fun `GIVEN the user has opted in the feature WHEN the user opts out THEN state should display not opted in UI`() =
+ runTest {
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
+ isEnabled = true,
+ isProductRecommendationsEnabled = true,
+ ),
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.OptOut).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expected = ReviewQualityCheckState.NotOptedIn()
+ assertEquals(expected, tested.state)
+ }
+
+ @Test
+ fun `GIVEN the user has opted in the feature and product recommendations feature is disabled THEN state should reflect that`() =
+ runTest {
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
+ isEnabled = true,
+ isProductRecommendationsEnabled = null,
+ ),
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.waitUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productRecommendationsPreference = null,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+ assertEquals(expected, tested.state)
+
+ // Even if toggle action is dispatched, state is not changed
+ tested.dispatch(ReviewQualityCheckAction.ToggleProductRecommendation).joinBlocking()
+ tested.waitUntilIdle()
+ assertEquals(expected, tested.state)
+ }
+
+ @Test
+ fun `GIVEN the user has opted in the feature and product recommendations are off WHEN the user turns on product recommendations THEN state should reflect that`() =
+ runTest {
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
+ isEnabled = true,
+ isProductRecommendationsEnabled = false,
+ ),
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.ToggleProductRecommendation).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productRecommendationsPreference = true,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+ assertEquals(expected, tested.state)
+ }
+
+ @Test
+ fun `GIVEN the user has opted in the feature and product recommendations are on WHEN the user turns off product recommendations THEN state should reflect that`() =
+ runTest {
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
+ isEnabled = true,
+ isProductRecommendationsEnabled = true,
+ ),
+ reviewQualityCheckService = FakeReviewQualityCheckService(
+ productAnalysis = { ProductAnalysisTestData.productAnalysis() },
+ productRecommendation = { ProductRecommendationTestData.productRecommendation() },
+ ),
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.ToggleProductRecommendation).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ productReviewState = ProductAnalysisTestData.analysisPresent(
+ recommendedProductState = ReviewQualityCheckState.RecommendedProductState.Initial,
+ ),
+ )
+ assertEquals(expected, tested.state)
+ }
+
+ @Test
+ fun `GIVEN product recommendations are available WHEN the user turns product recommendations off THEN state should not contain product recommendation`() =
+ runTest {
+ setAndResetLocale {
+ var productRecommendationsFetchCounter = 0
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
+ isEnabled = true,
+ isProductRecommendationsEnabled = true,
+ ),
+ reviewQualityCheckService = FakeReviewQualityCheckService(
+ productAnalysis = { ProductAnalysisTestData.productAnalysis() },
+ productRecommendation = {
+ productRecommendationsFetchCounter++
+ ProductRecommendationTestData.productRecommendation()
+ },
+ ),
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ assertEquals(1, productRecommendationsFetchCounter)
+
+ tested.dispatch(ReviewQualityCheckAction.ToggleProductRecommendation).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ productReviewState = ProductAnalysisTestData.analysisPresent(),
+ )
+ assertEquals(expected, tested.state)
+ assertEquals(1, productRecommendationsFetchCounter)
+ }
+ }
+
+ @Test
+ fun `GIVEN product recommendations are available WHEN the user turns product recommendations off and then back on THEN state should contain product recommendation`() =
+ runTest {
+ setAndResetLocale {
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
+ isEnabled = true,
+ isProductRecommendationsEnabled = true,
+ ),
+ reviewQualityCheckService = FakeReviewQualityCheckService(
+ productAnalysis = { ProductAnalysisTestData.productAnalysis() },
+ productRecommendation = { ProductRecommendationTestData.productRecommendation() },
+ ),
+ shoppingExperienceFeature = FakeShoppingExperienceFeature(
+ productRecommendationsExposureEnabled = true,
+ ),
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.ToggleProductRecommendation).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.ToggleProductRecommendation).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productRecommendationsPreference = true,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ productReviewState = ProductAnalysisTestData.analysisPresent(
+ recommendedProductState = ProductRecommendationTestData.product(),
+ ),
+ )
+ assertEquals(expected, tested.state)
+ }
+ }
+
+ @Test
+ fun `GIVEN product recommendations are available but analysis failed WHEN the user turns product recommendations on THEN recommendations should not be fetched`() =
+ runTest {
+ setAndResetLocale {
+ var productRecommendationsFetchCounter = 0
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
+ isEnabled = true,
+ isProductRecommendationsEnabled = false,
+ ),
+ reviewQualityCheckService = FakeReviewQualityCheckService(
+ productAnalysis = { null },
+ productRecommendation = {
+ productRecommendationsFetchCounter++
+ ProductRecommendationTestData.productRecommendation()
+ },
+ ),
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ assertEquals(0, productRecommendationsFetchCounter)
+
+ tested.dispatch(ReviewQualityCheckAction.ToggleProductRecommendation).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productRecommendationsPreference = true,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ productReviewState = ReviewQualityCheckState.OptedIn.ProductReviewState.Error.GenericError,
+ )
+ assertEquals(expected, tested.state)
+ assertEquals(0, productRecommendationsFetchCounter)
+ }
+ }
+
+ @Test
+ fun `GIVEN the user has opted in the feature WHEN there is existing card state data for a pdp THEN it should be restored`() =
+ runTest {
+ val captureActionsMiddleware = CaptureActionsMiddleware<AppState, AppAction>()
+ val appStore = AppStore(
+ initialState = AppState(
+ shoppingState = ShoppingState(
+ productCardState = mapOf(
+ "pdp" to ShoppingState.CardState(
+ isHighlightsExpanded = false,
+ isSettingsExpanded = true,
+ isInfoExpanded = true,
+ ),
+ ),
+ ),
+ ),
+ middlewares = listOf(captureActionsMiddleware),
+ )
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
+ isEnabled = true,
+ ),
+ reviewQualityCheckVendorsService = FakeReviewQualityCheckVendorsService(
+ selectedTabUrl = "pdp",
+ ),
+ appStore = appStore,
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.waitUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ isHighlightsExpanded = false,
+ isSettingsExpanded = true,
+ isInfoExpanded = true,
+ )
+ assertEquals(expected, tested.state)
+ }
+
+ @Test
+ fun `GIVEN the user has opted in the feature WHEN the user expands settings THEN state should reflect that`() =
+ runTest {
+ val captureActionsMiddleware = CaptureActionsMiddleware<AppState, AppAction>()
+ val appStore = AppStore(middlewares = listOf(captureActionsMiddleware))
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
+ isEnabled = true,
+ ),
+ reviewQualityCheckVendorsService = FakeReviewQualityCheckVendorsService(
+ selectedTabUrl = "pdp",
+ ),
+ appStore = appStore,
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.ExpandCollapseSettings).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ appStore.waitUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ isSettingsExpanded = true,
+ )
+ assertEquals(expected, tested.state)
+ captureActionsMiddleware.assertFirstAction(SettingsCardExpanded::class) {
+ assertEquals(SettingsCardExpanded("pdp", true), it)
+ }
+ }
+
+ @Test
+ fun `GIVEN the user has opted in the feature WHEN the user collapses settings THEN state should reflect that`() =
+ runTest {
+ val captureActionsMiddleware = CaptureActionsMiddleware<AppState, AppAction>()
+ val appStore = AppStore(
+ initialState = AppState(
+ shoppingState = ShoppingState(
+ productCardState = mapOf(
+ "pdp" to ShoppingState.CardState(isSettingsExpanded = true),
+ ),
+ ),
+ ),
+ middlewares = listOf(captureActionsMiddleware),
+ )
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
+ isEnabled = true,
+ ),
+ reviewQualityCheckVendorsService = FakeReviewQualityCheckVendorsService(
+ selectedTabUrl = "pdp",
+ ),
+ appStore = appStore,
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.ExpandCollapseSettings).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ appStore.waitUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ isSettingsExpanded = false,
+ )
+ assertEquals(expected, tested.state)
+ captureActionsMiddleware.assertFirstAction(SettingsCardExpanded::class) {
+ assertEquals(SettingsCardExpanded("pdp", false), it)
+ }
+ }
+
+ @Test
+ fun `GIVEN the user has opted in the feature WHEN the user expands info card THEN state should reflect that`() =
+ runTest {
+ val captureActionsMiddleware = CaptureActionsMiddleware<AppState, AppAction>()
+ val appStore = AppStore(middlewares = listOf(captureActionsMiddleware))
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
+ isEnabled = true,
+ ),
+ reviewQualityCheckVendorsService = FakeReviewQualityCheckVendorsService(
+ selectedTabUrl = "pdp",
+ ),
+ appStore = appStore,
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.ExpandCollapseInfo).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ appStore.waitUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ isInfoExpanded = true,
+ )
+ assertEquals(expected, tested.state)
+ captureActionsMiddleware.assertFirstAction(InfoCardExpanded::class) {
+ assertEquals(InfoCardExpanded("pdp", true), it)
+ }
+ }
+
+ @Test
+ fun `GIVEN the user has opted in the feature WHEN the user collapses info card THEN state should reflect that`() =
+ runTest {
+ val captureActionsMiddleware = CaptureActionsMiddleware<AppState, AppAction>()
+ val appStore = AppStore(
+ initialState = AppState(
+ shoppingState = ShoppingState(
+ productCardState = mapOf(
+ "pdp" to ShoppingState.CardState(isInfoExpanded = true),
+ ),
+ ),
+ ),
+ middlewares = listOf(captureActionsMiddleware),
+ )
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
+ isEnabled = true,
+ ),
+ reviewQualityCheckVendorsService = FakeReviewQualityCheckVendorsService(
+ selectedTabUrl = "pdp",
+ ),
+ appStore = appStore,
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.ExpandCollapseInfo).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ appStore.waitUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ isInfoExpanded = false,
+ )
+ assertEquals(expected, tested.state)
+ captureActionsMiddleware.assertFirstAction(InfoCardExpanded::class) {
+ assertEquals(InfoCardExpanded("pdp", false), it)
+ }
+ }
+
+ @Test
+ fun `GIVEN the user has opted in the feature WHEN the user expands highlights card THEN state should reflect that`() =
+ runTest {
+ val captureActionsMiddleware = CaptureActionsMiddleware<AppState, AppAction>()
+ val appStore = AppStore(middlewares = listOf(captureActionsMiddleware))
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
+ isEnabled = true,
+ ),
+ reviewQualityCheckVendorsService = FakeReviewQualityCheckVendorsService(
+ selectedTabUrl = "pdp",
+ ),
+ appStore = appStore,
+ ),
+ )
+
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.ExpandCollapseHighlights).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ appStore.waitUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ isHighlightsExpanded = true,
+ )
+ assertEquals(expected, tested.state)
+
+ captureActionsMiddleware.assertFirstAction(HighlightsCardExpanded::class) {
+ assertEquals(HighlightsCardExpanded("pdp", true), it)
+ }
+ }
+
+ @Test
+ fun `GIVEN the user has opted in the feature WHEN the user collapses highlights card THEN state should reflect that`() =
+ runTest {
+ val captureActionsMiddleware = CaptureActionsMiddleware<AppState, AppAction>()
+ val appStore = AppStore(
+ initialState = AppState(
+ shoppingState = ShoppingState(
+ productCardState = mapOf(
+ "pdp" to ShoppingState.CardState(isHighlightsExpanded = true),
+ ),
+ ),
+ ),
+ middlewares = listOf(captureActionsMiddleware),
+ )
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
+ isEnabled = true,
+ ),
+ reviewQualityCheckVendorsService = FakeReviewQualityCheckVendorsService(
+ selectedTabUrl = "pdp",
+ ),
+ appStore = appStore,
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.ExpandCollapseHighlights).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ appStore.waitUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ isHighlightsExpanded = false,
+ )
+ assertEquals(expected, tested.state)
+ captureActionsMiddleware.assertFirstAction(HighlightsCardExpanded::class) {
+ assertEquals(HighlightsCardExpanded("pdp", false), it)
+ }
+ }
+
+ @Test
+ fun `GIVEN the user has opted in the feature WHEN a product analysis is fetched successfully THEN state should reflect that`() =
+ runTest {
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(isEnabled = true),
+ reviewQualityCheckService = FakeReviewQualityCheckService(
+ productAnalysis = { ProductAnalysisTestData.productAnalysis() },
+ ),
+ networkChecker = FakeNetworkChecker(isConnected = true),
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productReviewState = ProductAnalysisTestData.analysisPresent(),
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+ assertEquals(expected, tested.state)
+ }
+
+ @Test
+ fun `GIVEN the user has opted in the feature WHEN a product analysis returns an error THEN state should reflect that`() =
+ runTest {
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(isEnabled = true),
+ reviewQualityCheckService = FakeReviewQualityCheckService(),
+ networkChecker = FakeNetworkChecker(isConnected = true),
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productReviewState = ReviewQualityCheckState.OptedIn.ProductReviewState.Error.GenericError,
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+ assertEquals(expected, tested.state)
+ }
+
+ @Test
+ fun `GIVEN the user has opted in the feature WHEN device is not connected to the internet THEN state should reflect that`() =
+ runTest {
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(isEnabled = true),
+ reviewQualityCheckService = FakeReviewQualityCheckService(),
+ networkChecker = FakeNetworkChecker(isConnected = false),
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productReviewState = ReviewQualityCheckState.OptedIn.ProductReviewState.Error.NetworkError,
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+ assertEquals(expected, tested.state)
+ }
+
+ @Test
+ fun `WHEN reanalysis api call fails THEN state should reflect that`() =
+ runTest {
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(isEnabled = true),
+ reviewQualityCheckService = FakeReviewQualityCheckService(
+ reanalysis = null,
+ ),
+ networkChecker = FakeNetworkChecker(isConnected = true),
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.ReanalyzeProduct).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productReviewState = ReviewQualityCheckState.OptedIn.ProductReviewState.Error.GenericError,
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+ assertEquals(expected, tested.state)
+ }
+
+ @Test
+ fun `GIVEN a product analysis WHEN reanalysis call succeeds and status fails THEN state should reflect that`() =
+ runTest {
+ val productAnalysisList = listOf(
+ ProductAnalysisTestData.productAnalysis(
+ needsAnalysis = true,
+ grade = "B",
+ ),
+ ProductAnalysisTestData.productAnalysis(),
+ )
+
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(isEnabled = true),
+ reviewQualityCheckService = FakeReviewQualityCheckService(
+ reanalysis = AnalysisStatusDto.PENDING,
+ statusProgress = { null },
+ productAnalysis = { productAnalysisList[it] },
+ ),
+ networkChecker = FakeNetworkChecker(isConnected = true),
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.ReanalyzeProduct).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productReviewState = ProductAnalysisTestData.analysisPresent(
+ reviewGrade = ReviewQualityCheckState.Grade.B,
+ analysisStatus = AnalysisStatus.NeedsAnalysis,
+ ),
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+ assertEquals(expected, tested.state)
+ }
+
+ @Test
+ fun `WHEN reanalysis and status api call succeeds THEN analysis should be fetched and displayed`() =
+ runTest {
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(isEnabled = true),
+ reviewQualityCheckService = FakeReviewQualityCheckService(
+ productAnalysis = { ProductAnalysisTestData.productAnalysis() },
+ reanalysis = AnalysisStatusDto.PENDING,
+ statusProgress = {
+ AnalysisStatusProgressDto(
+ status = AnalysisStatusDto.COMPLETED,
+ progress = 100.0,
+ )
+ },
+ ),
+ networkChecker = FakeNetworkChecker(isConnected = true),
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.ReanalyzeProduct).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productReviewState = ProductAnalysisTestData.analysisPresent(),
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+ assertEquals(expected, tested.state)
+ }
+
+ @Test
+ fun `GIVEN reanalysis and status api call succeeds WHEN notEnoughReviews is true THEN not enough reviews card is displayed`() =
+ runTest {
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(isEnabled = true),
+ reviewQualityCheckService = FakeReviewQualityCheckService(
+ productAnalysis = {
+ ProductAnalysisTestData.productAnalysis(
+ notEnoughReviews = true,
+ )
+ },
+ reanalysis = AnalysisStatusDto.PENDING,
+ statusProgress = {
+ AnalysisStatusProgressDto(
+ status = AnalysisStatusDto.COMPLETED,
+ progress = 100.0,
+ )
+ },
+ ),
+ networkChecker = FakeNetworkChecker(isConnected = true),
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.ReanalyzeProduct).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productReviewState = ReviewQualityCheckState.OptedIn.ProductReviewState.Error.NotEnoughReviews,
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+ assertEquals(expected, tested.state)
+ }
+
+ @Ignore("Disabled until intermittent is resolved. See Bug 1869566")
+ @Test
+ fun `GIVEN a product analysis WHEN analysis status is in progress or pending THEN state should be updated to reanalysing`() =
+ runTest {
+ val statusProgressResponses = listOf(
+ AnalysisStatusProgressDto(
+ status = AnalysisStatusDto.IN_PROGRESS,
+ progress = 50.0,
+ ),
+ AnalysisStatusProgressDto(
+ status = AnalysisStatusDto.IN_PROGRESS,
+ progress = 61.6,
+ ),
+ AnalysisStatusProgressDto(
+ status = AnalysisStatusDto.IN_PROGRESS,
+ progress = 92.52,
+ ),
+ AnalysisStatusProgressDto(
+ status = AnalysisStatusDto.COMPLETED,
+ progress = 100.0,
+ ),
+ )
+ var counter = 0
+
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(isEnabled = true),
+ reviewQualityCheckService = FakeReviewQualityCheckService(
+ productAnalysis = {
+ ProductAnalysisTestData.productAnalysis()
+ },
+ reanalysis = AnalysisStatusDto.PENDING,
+ statusProgress = {
+ statusProgressResponses[counter].also { counter++ }
+ },
+ ),
+ networkChecker = FakeNetworkChecker(isConnected = true),
+ ),
+ )
+
+ val observedState = mutableListOf<ReviewQualityCheckState>()
+ tested.observeForever {
+ observedState.add(it)
+ }
+
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expectedProgressState1 = ReviewQualityCheckState.OptedIn(
+ productReviewState = ProductAnalysisTestData.analysisPresent(
+ analysisStatus = AnalysisStatus.Reanalyzing(Progress(61.6f)),
+ ),
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+
+ val expectedProgressState2 = expectedProgressState1.copy(
+ productReviewState = ProductAnalysisTestData.analysisPresent(
+ analysisStatus = AnalysisStatus.Reanalyzing(Progress(92.52f)),
+ ),
+ )
+
+ // Since reanalyzing is an intermediate state and the tests completes to get to the final
+ // state, this checks if the intermediate state is present in the observed state.
+ assertTrue(observedState.contains(expectedProgressState1))
+ assertTrue(observedState.contains(expectedProgressState2))
+ }
+
+ @Ignore("Disabled until intermittent is resolved. See Bug 1869566")
+ @Test
+ fun `GIVEN reanalyse product is clicked WHEN analysis status is in progress or pending THEN state should be updated to reanalysing`() =
+ runTest {
+ val statusProgressResponses = listOf(
+ AnalysisStatusProgressDto(
+ status = AnalysisStatusDto.OTHER,
+ progress = 0.0,
+ ),
+ AnalysisStatusProgressDto(
+ status = AnalysisStatusDto.IN_PROGRESS,
+ progress = 30.0,
+ ),
+ AnalysisStatusProgressDto(
+ status = AnalysisStatusDto.IN_PROGRESS,
+ progress = 61.6,
+ ),
+ AnalysisStatusProgressDto(
+ status = AnalysisStatusDto.IN_PROGRESS,
+ progress = 92.52,
+ ),
+ AnalysisStatusProgressDto(
+ status = AnalysisStatusDto.COMPLETED,
+ progress = 100.0,
+ ),
+ )
+ var counter = 0
+
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(isEnabled = true),
+ reviewQualityCheckService = FakeReviewQualityCheckService(
+ productAnalysis = {
+ ProductAnalysisTestData.productAnalysis()
+ },
+ reanalysis = AnalysisStatusDto.PENDING,
+ statusProgress = {
+ statusProgressResponses[counter].also { counter++ }
+ },
+ ),
+ networkChecker = FakeNetworkChecker(isConnected = true),
+ ),
+ )
+
+ val observedState = mutableListOf<ReviewQualityCheckState>()
+ tested.observeForever {
+ observedState.add(it)
+ }
+
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.ReanalyzeProduct).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expectedProgressState1 = ReviewQualityCheckState.OptedIn(
+ productReviewState = ProductAnalysisTestData.analysisPresent(
+ analysisStatus = AnalysisStatus.Reanalyzing(Progress(30f)),
+ ),
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+
+ val expectedProgressState2 = expectedProgressState1.copy(
+ productReviewState = ProductAnalysisTestData.analysisPresent(
+ analysisStatus = AnalysisStatus.Reanalyzing(Progress(61.6f)),
+ ),
+ )
+
+ val expectedProgressState3 = expectedProgressState1.copy(
+ productReviewState = ProductAnalysisTestData.analysisPresent(
+ analysisStatus = AnalysisStatus.Reanalyzing(Progress(92.52f)),
+ ),
+ )
+
+ // Since reanalyzing is an intermediate state and the tests completes to get to the final
+ // state, this checks if the intermediate state is present in the observed state.
+ assertTrue(observedState.contains(expectedProgressState1))
+ assertTrue(observedState.contains(expectedProgressState2))
+ assertTrue(observedState.contains(expectedProgressState3))
+ }
+
+ @Test
+ fun `GIVEN a product analysis WHEN analysis status is completed THEN state should display analysis as usual`() =
+ runTest {
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(isEnabled = true),
+ reviewQualityCheckService = FakeReviewQualityCheckService(
+ productAnalysis = {
+ ProductAnalysisTestData.productAnalysis(needsAnalysis = true)
+ },
+ reanalysis = AnalysisStatusDto.COMPLETED,
+ statusProgress = {
+ AnalysisStatusProgressDto(
+ status = AnalysisStatusDto.COMPLETED,
+ progress = 100.0,
+ )
+ },
+ ),
+ networkChecker = FakeNetworkChecker(isConnected = true),
+ ),
+ )
+
+ val observedState = mutableListOf<ReviewQualityCheckState>()
+ tested.observeForever {
+ observedState.add(it)
+ }
+
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productReviewState = ProductAnalysisTestData.analysisPresent(
+ analysisStatus = AnalysisStatus.NeedsAnalysis,
+ ),
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+ assertEquals(expected, tested.state)
+
+ val notExpected = ReviewQualityCheckState.OptedIn(
+ productReviewState = ProductAnalysisTestData.analysisPresent(
+ analysisStatus = AnalysisStatus.Reanalyzing(Progress(0f)),
+ ),
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+ assertFalse(observedState.contains(notExpected))
+ }
+
+ @Test
+ fun `GIVEN a product analysis WHEN analysis status fails THEN state should display analysis as usual`() =
+ runTest {
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(isEnabled = true),
+ reviewQualityCheckService = FakeReviewQualityCheckService(
+ productAnalysis = {
+ ProductAnalysisTestData.productAnalysis()
+ },
+ reanalysis = null,
+ statusProgress = { null },
+ ),
+ networkChecker = FakeNetworkChecker(isConnected = true),
+ ),
+ )
+
+ val observedState = mutableListOf<ReviewQualityCheckState>()
+ tested.observeForever {
+ observedState.add(it)
+ }
+
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productReviewState = ProductAnalysisTestData.analysisPresent(),
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+ assertEquals(expected, tested.state)
+
+ val notExpected = ReviewQualityCheckState.OptedIn(
+ productReviewState = ProductAnalysisTestData.analysisPresent(
+ analysisStatus = AnalysisStatus.Reanalyzing(Progress(0f)),
+ ),
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+ assertFalse(observedState.contains(notExpected))
+ }
+
+ @Ignore("Disabled until intermittent is resolved. See Bug 1869566")
+ @Test
+ fun `GIVEN no analysis WHEN analysis is in progress THEN state should update to no analysis present with progress`() =
+ runTest {
+ val statusProgressResponses = listOf(
+ AnalysisStatusProgressDto(
+ status = AnalysisStatusDto.IN_PROGRESS,
+ progress = 30.0,
+ ),
+ AnalysisStatusProgressDto(
+ status = AnalysisStatusDto.IN_PROGRESS,
+ progress = 61.6,
+ ),
+ AnalysisStatusProgressDto(
+ status = AnalysisStatusDto.IN_PROGRESS,
+ progress = 92.52,
+ ),
+ AnalysisStatusProgressDto(
+ status = AnalysisStatusDto.COMPLETED,
+ progress = 100.0,
+ ),
+ )
+ var counter = 0
+
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(isEnabled = true),
+ reviewQualityCheckService = FakeReviewQualityCheckService(
+ productAnalysis = {
+ ProductAnalysisTestData.productAnalysis(
+ grade = null,
+ adjustedRating = null,
+ needsAnalysis = true,
+ )
+ },
+ reanalysis = AnalysisStatusDto.PENDING,
+ statusProgress = {
+ statusProgressResponses[counter].also { counter++ }
+ },
+ ),
+ networkChecker = FakeNetworkChecker(isConnected = true),
+ ),
+ )
+
+ val observedState = mutableListOf<ReviewQualityCheckState>()
+ tested.observeForever {
+ observedState.add(it)
+ }
+
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expectedProgressState1 = ReviewQualityCheckState.OptedIn(
+ productReviewState = ProductAnalysisTestData.noAnalysisPresent(
+ progress = 61.6f,
+ ),
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+
+ val expectedProgressState2 = expectedProgressState1.copy(
+ productReviewState = ProductAnalysisTestData.noAnalysisPresent(
+ progress = 92.52f,
+ ),
+ )
+
+ // Since reanalyzing is an intermediate state and the tests completes to get to the final
+ // state, this checks if the intermediate state is present in the observed state.
+ assertTrue(observedState.contains(expectedProgressState1))
+ assertTrue(observedState.contains(expectedProgressState2))
+ }
+
+ @Ignore("Disabled until intermittent is resolved. See Bug 1869566")
+ @Test
+ fun `GIVEN no analysis WHEN reanalyze THEN state should update to no analysis present with progress`() =
+ runTest {
+ val statusProgressResponses = listOf(
+ AnalysisStatusProgressDto(
+ status = AnalysisStatusDto.OTHER,
+ progress = 0.0,
+ ),
+ AnalysisStatusProgressDto(
+ status = AnalysisStatusDto.IN_PROGRESS,
+ progress = 30.0,
+ ),
+ AnalysisStatusProgressDto(
+ status = AnalysisStatusDto.IN_PROGRESS,
+ progress = 61.6,
+ ),
+ AnalysisStatusProgressDto(
+ status = AnalysisStatusDto.IN_PROGRESS,
+ progress = 92.52,
+ ),
+ AnalysisStatusProgressDto(
+ status = AnalysisStatusDto.COMPLETED,
+ progress = 100.0,
+ ),
+ )
+ var counter = 0
+
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(isEnabled = true),
+ reviewQualityCheckService = FakeReviewQualityCheckService(
+ productAnalysis = {
+ ProductAnalysisTestData.productAnalysis(
+ grade = null,
+ adjustedRating = null,
+ needsAnalysis = true,
+ )
+ },
+ reanalysis = AnalysisStatusDto.PENDING,
+ statusProgress = {
+ statusProgressResponses[counter].also { counter++ }
+ },
+ ),
+ networkChecker = FakeNetworkChecker(isConnected = true),
+ ),
+ )
+
+ val observedState = mutableListOf<ReviewQualityCheckState>()
+ tested.observeForever {
+ observedState.add(it)
+ }
+
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.ReanalyzeProduct).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expectedProgressState1 = ReviewQualityCheckState.OptedIn(
+ productReviewState = ProductAnalysisTestData.noAnalysisPresent(
+ progress = 30f,
+ ),
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+
+ val expectedProgressState2 = expectedProgressState1.copy(
+ productReviewState = ProductAnalysisTestData.noAnalysisPresent(
+ progress = 61.6f,
+ ),
+ )
+
+ val expectedProgressState3 = expectedProgressState1.copy(
+ productReviewState = ProductAnalysisTestData.noAnalysisPresent(
+ progress = 92.52f,
+ ),
+ )
+
+ // Since reanalyzing is an intermediate state and the tests completes to get to the final
+ // state, this checks if the intermediate state is present in the observed state.
+ assertTrue(observedState.contains(expectedProgressState1))
+ assertTrue(observedState.contains(expectedProgressState2))
+ assertTrue(observedState.contains(expectedProgressState3))
+ }
+
+ @Test
+ fun `GIVEN deletedProduct is true and deletedProductReported is false WHEN report back in stock is clicked THEN thanks for reporting card is shown`() =
+ runTest {
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(isEnabled = true),
+ reviewQualityCheckService = FakeReviewQualityCheckService(
+ productAnalysis = {
+ ProductAnalysisTestData.productAnalysis(
+ deletedProduct = true,
+ deletedProductReported = false,
+ )
+ },
+ reanalysis = AnalysisStatusDto.PENDING,
+ statusProgress = { null },
+ report = ReportBackInStockStatusDto.REPORT_CREATED,
+ ),
+ networkChecker = FakeNetworkChecker(isConnected = true),
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productReviewState = ReviewQualityCheckState.OptedIn.ProductReviewState.Error.ProductNotAvailable,
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+ assertEquals(expected, tested.state)
+
+ tested.dispatch(ReviewQualityCheckAction.ReportProductBackInStock).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val reportExpected = expected.copy(
+ productReviewState = ReviewQualityCheckState.OptedIn.ProductReviewState.Error.ThanksForReporting,
+ )
+ assertEquals(reportExpected, tested.state)
+ }
+
+ @Test
+ fun `GIVEN ProductNotAvailable WHEN report back in stock returns not deleted THEN state is updated to no analysis`() =
+ runTest {
+ val tested = ReviewQualityCheckStore(
+ initialState = ReviewQualityCheckState.OptedIn(
+ productReviewState = ReviewQualityCheckState.OptedIn.ProductReviewState.Error.ProductNotAvailable,
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ ),
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(isEnabled = true),
+ reviewQualityCheckService = FakeReviewQualityCheckService(
+ productAnalysis = {
+ ProductAnalysisTestData.productAnalysis(
+ grade = null,
+ adjustedRating = null,
+ needsAnalysis = true,
+ )
+ },
+ reanalysis = AnalysisStatusDto.PENDING,
+ statusProgress = { null },
+ report = ReportBackInStockStatusDto.NOT_DELETED,
+ ),
+ networkChecker = FakeNetworkChecker(isConnected = true),
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.ReportProductBackInStock).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productReviewState = ProductAnalysisTestData.noAnalysisPresent(progress = -1f),
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+ assertEquals(expected, tested.state)
+ }
+
+ @Test
+ fun `GIVEN product recommendations are enabled WHEN a product analysis is fetched successfully THEN product recommendation should also be fetched and displayed if available`() =
+ runTest {
+ setAndResetLocale {
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
+ isEnabled = true,
+ isProductRecommendationsEnabled = true,
+ ),
+ reviewQualityCheckService = FakeReviewQualityCheckService(
+ productAnalysis = { ProductAnalysisTestData.productAnalysis() },
+ productRecommendation = { ProductRecommendationTestData.productRecommendation() },
+ ),
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productReviewState = ProductAnalysisTestData.analysisPresent(
+ recommendedProductState = ProductRecommendationTestData.product(),
+ ),
+ productRecommendationsPreference = true,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+ assertEquals(expected, tested.state)
+ }
+ }
+
+ @Test
+ fun `GIVEN product recommendations are disabled WHEN a product analysis is fetched successfully and exposure is set to true THEN product recommendation should also be fetched`() =
+ runTest {
+ setAndResetLocale {
+ var productRecommendationFetched = false
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
+ isEnabled = true,
+ isProductRecommendationsEnabled = false,
+ ),
+ shoppingExperienceFeature = FakeShoppingExperienceFeature(
+ productRecommendationsExposureEnabled = true,
+ ),
+ reviewQualityCheckService = FakeReviewQualityCheckService(
+ productAnalysis = { ProductAnalysisTestData.productAnalysis() },
+ productRecommendation = {
+ productRecommendationFetched = true
+ ProductRecommendationTestData.productRecommendation()
+ },
+ ),
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productReviewState = ProductAnalysisTestData.analysisPresent(),
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+ assertEquals(expected, tested.state)
+ assertTrue(productRecommendationFetched)
+ }
+ }
+
+ @Test
+ fun `GIVEN product recommendations are disabled WHEN a product analysis is fetched successfully and exposure is set to false THEN product recommendation should not be fetched and displayed`() =
+ runTest {
+ setAndResetLocale {
+ var productRecommendationFetched = false
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
+ isEnabled = true,
+ isProductRecommendationsEnabled = false,
+ ),
+ shoppingExperienceFeature = FakeShoppingExperienceFeature(
+ productRecommendationsExposureEnabled = false,
+ ),
+ reviewQualityCheckService = FakeReviewQualityCheckService(
+ productAnalysis = { ProductAnalysisTestData.productAnalysis() },
+ productRecommendation = {
+ productRecommendationFetched = true
+ ProductRecommendationTestData.productRecommendation()
+ },
+ ),
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productReviewState = ProductAnalysisTestData.analysisPresent(),
+ productRecommendationsPreference = false,
+ productRecommendationsExposure = false,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+ assertEquals(expected, tested.state)
+ assertFalse(productRecommendationFetched)
+ }
+ }
+
+ @Test
+ fun `GIVEN product recommendations are enabled WHEN a product analysis is fetched successfully and exposure is set to false THEN product recommendation should be fetched and displayed`() =
+ runTest {
+ setAndResetLocale {
+ var productRecommendationFetched = false
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
+ isEnabled = true,
+ isProductRecommendationsEnabled = true,
+ ),
+ shoppingExperienceFeature = FakeShoppingExperienceFeature(
+ productRecommendationsExposureEnabled = false,
+ ),
+ reviewQualityCheckService = FakeReviewQualityCheckService(
+ productAnalysis = { ProductAnalysisTestData.productAnalysis() },
+ productRecommendation = {
+ productRecommendationFetched = true
+ ProductRecommendationTestData.productRecommendation()
+ },
+ ),
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productReviewState = ProductAnalysisTestData.analysisPresent(
+ recommendedProductState = ProductRecommendationTestData.product(),
+ ),
+ productRecommendationsPreference = true,
+ productRecommendationsExposure = false,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+ assertEquals(expected, tested.state)
+ assertTrue(productRecommendationFetched)
+ }
+ }
+
+ @Test
+ fun `GIVEN product recommendations are enabled WHEN a product analysis is fetched successfully and product recommendation fails THEN product recommendations state should be initial`() =
+ runTest {
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
+ isEnabled = true,
+ isProductRecommendationsEnabled = true,
+ ),
+ reviewQualityCheckService = FakeReviewQualityCheckService(
+ productAnalysis = { ProductAnalysisTestData.productAnalysis() },
+ productRecommendation = { null },
+ ),
+ ),
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val expected = ReviewQualityCheckState.OptedIn(
+ productReviewState = ProductAnalysisTestData.analysisPresent(
+ recommendedProductState = ReviewQualityCheckState.RecommendedProductState.Initial,
+ ),
+ productRecommendationsPreference = true,
+ productRecommendationsExposure = true,
+ productVendor = ProductVendor.BEST_BUY,
+ )
+ assertEquals(expected, tested.state)
+ }
+
+ @Test
+ fun `GIVEN product recommendations are enabled WHEN product analysis fails THEN product recommendations should not be fetched`() =
+ runTest {
+ val captureActionsMiddleware =
+ CaptureActionsMiddleware<ReviewQualityCheckState, ReviewQualityCheckAction>()
+ val tested = ReviewQualityCheckStore(
+ middleware = provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
+ isEnabled = true,
+ isProductRecommendationsEnabled = true,
+ ),
+ reviewQualityCheckService = FakeReviewQualityCheckService(
+ productAnalysis = { null },
+ productRecommendation = { ProductRecommendationTestData.productRecommendation() },
+ ),
+ ) + captureActionsMiddleware,
+ )
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+ tested.waitUntilIdle()
+ tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
+ tested.waitUntilIdle()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ captureActionsMiddleware.assertNotDispatched(ReviewQualityCheckAction.UpdateRecommendedProduct::class)
+ }
+
+ private fun provideReviewQualityCheckMiddleware(
+ reviewQualityCheckPreferences: ReviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(),
+ reviewQualityCheckVendorsService: FakeReviewQualityCheckVendorsService = FakeReviewQualityCheckVendorsService(),
+ reviewQualityCheckService: ReviewQualityCheckService = FakeReviewQualityCheckService(),
+ networkChecker: NetworkChecker = FakeNetworkChecker(),
+ shoppingExperienceFeature: ShoppingExperienceFeature = FakeShoppingExperienceFeature(),
+ appStore: AppStore = AppStore(),
+ ): List<ReviewQualityCheckMiddleware> {
+ return listOf(
+ ReviewQualityCheckPreferencesMiddleware(
+ reviewQualityCheckPreferences = reviewQualityCheckPreferences,
+ reviewQualityCheckVendorsService = reviewQualityCheckVendorsService,
+ appStore = appStore,
+ shoppingExperienceFeature = shoppingExperienceFeature,
+ scope = this.scope,
+ ),
+ ReviewQualityCheckNetworkMiddleware(
+ reviewQualityCheckService = reviewQualityCheckService,
+ networkChecker = networkChecker,
+ scope = this.scope,
+ ),
+ )
+ }
+
+ private fun setAndResetLocale(locale: Locale = Locale.US, block: () -> Unit) {
+ val initialLocale = Locale.getDefault()
+ Locale.setDefault(locale)
+ block()
+ Locale.setDefault(initialLocale)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/ui/StarRatingKtTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/ui/StarRatingKtTest.kt
new file mode 100644
index 0000000000..6dc1f064a3
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shopping/ui/StarRatingKtTest.kt
@@ -0,0 +1,31 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.shopping.ui
+
+import junit.framework.TestCase.assertEquals
+import org.junit.Test
+
+class StarRatingKtTest {
+
+ @Test
+ fun `GIVEN a float with a decimal THEN remove the zero if present`() {
+ assertEquals(3, 3.0f.removeDecimalZero())
+ assertEquals(4.5f, 4.5f.removeDecimalZero())
+ assertEquals(5, 5.0f.removeDecimalZero())
+ assertEquals(0.2f, 0.2f.removeDecimalZero())
+ assertEquals(1.00001f, 1.00001f.removeDecimalZero())
+ assertEquals(4.999f, 4.999f.removeDecimalZero())
+ }
+
+ @Test
+ fun `GIVEN a float with a decimal THEN round its value to the nearest half`() {
+ assertEquals(3.0f, 3.1f.roundToNearestHalf())
+ assertEquals(4.5f, 4.6f.roundToNearestHalf())
+ assertEquals(5.0f, 4.9f.roundToNearestHalf())
+ assertEquals(0.5f, 0.3f.roundToNearestHalf())
+ assertEquals(2.5f, 2.26f.roundToNearestHalf())
+ assertEquals(2.0f, 2.25f.roundToNearestHalf())
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shortcut/PwaOnboardingObserverTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shortcut/PwaOnboardingObserverTest.kt
new file mode 100644
index 0000000000..7fcb8821e5
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/shortcut/PwaOnboardingObserverTest.kt
@@ -0,0 +1,117 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.shortcut
+
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.navigation.NavController
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.feature.pwa.WebAppUseCases
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class PwaOnboardingObserverTest {
+
+ private lateinit var store: BrowserStore
+ private lateinit var lifecycleOwner: MockedLifecycleOwner
+ private lateinit var pwaOnboardingObserver: PwaOnboardingObserver
+ private lateinit var navigationController: NavController
+ private lateinit var settings: Settings
+ private lateinit var webAppUseCases: WebAppUseCases
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Before
+ fun setUp() {
+ store = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab(url = "https://firefox.com", id = "1"),
+ ),
+ selectedTabId = "1",
+ ),
+ )
+ lifecycleOwner = MockedLifecycleOwner(Lifecycle.State.STARTED)
+
+ navigationController = mockk(relaxed = true)
+ settings = mockk(relaxed = true)
+ webAppUseCases = mockk(relaxed = true)
+
+ pwaOnboardingObserver = spyk(
+ PwaOnboardingObserver(
+ store = store,
+ lifecycleOwner = lifecycleOwner,
+ navController = navigationController,
+ settings = settings,
+ webAppUseCases = webAppUseCases,
+ ),
+ )
+ every { pwaOnboardingObserver.navigateToPwaOnboarding() } returns Unit
+ }
+
+ @After
+ fun teardown() {
+ pwaOnboardingObserver.stop()
+ }
+
+ @Test
+ fun `GIVEN cfr should not yet be shown WHEN installable page is loaded THEN counter is incremented`() {
+ every { webAppUseCases.isInstallable() } returns true
+ every { settings.userKnowsAboutPwas } returns false
+ every { settings.shouldShowPwaCfr } returns false
+ pwaOnboardingObserver.start()
+
+ store.dispatch(ContentAction.UpdateWebAppManifestAction("1", mockk())).joinBlocking()
+ verify { settings.incrementVisitedInstallableCount() }
+ verify(exactly = 0) { pwaOnboardingObserver.navigateToPwaOnboarding() }
+ }
+
+ @Test
+ fun `GIVEN cfr should be shown WHEN installable page is loaded THEN we navigate to onboarding fragment`() {
+ every { webAppUseCases.isInstallable() } returns true
+ every { settings.userKnowsAboutPwas } returns false
+ every { settings.shouldShowPwaCfr } returns true
+ pwaOnboardingObserver.start()
+
+ store.dispatch(ContentAction.UpdateWebAppManifestAction("1", mockk())).joinBlocking()
+ verify { settings.incrementVisitedInstallableCount() }
+ verify { pwaOnboardingObserver.navigateToPwaOnboarding() }
+ }
+
+ @Test
+ fun `GIVEN web app is not installable WHEN page with manifest is loaded THEN nothing happens`() {
+ every { webAppUseCases.isInstallable() } returns false
+ every { settings.userKnowsAboutPwas } returns false
+ every { settings.shouldShowPwaCfr } returns true
+ pwaOnboardingObserver.start()
+
+ store.dispatch(ContentAction.UpdateWebAppManifestAction("1", mockk())).joinBlocking()
+ verify(exactly = 0) { settings.incrementVisitedInstallableCount() }
+ verify(exactly = 0) { pwaOnboardingObserver.navigateToPwaOnboarding() }
+ }
+
+ internal class MockedLifecycleOwner(initialState: Lifecycle.State) : LifecycleOwner {
+ override val lifecycle: Lifecycle = LifecycleRegistry(this).apply {
+ currentState = initialState
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/sync/SyncedTabsIntegrationTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/sync/SyncedTabsIntegrationTest.kt
new file mode 100644
index 0000000000..29af9d5c7e
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/sync/SyncedTabsIntegrationTest.kt
@@ -0,0 +1,57 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.sync
+
+import android.content.Context
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import mozilla.components.concept.sync.AccountObserver
+import mozilla.components.feature.syncedtabs.storage.SyncedTabsStorage
+import mozilla.components.service.fxa.manager.FxaAccountManager
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.FenixApplication
+
+class SyncedTabsIntegrationTest {
+
+ @MockK private lateinit var context: Context
+
+ @MockK private lateinit var syncedTabsStorage: SyncedTabsStorage
+
+ @MockK private lateinit var accountManager: FxaAccountManager
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ every { syncedTabsStorage.stop() } just Runs
+ every { accountManager.register(any(), owner = any(), autoPause = true) } just Runs
+ every { context.applicationContext } returns mockk<FenixApplication> {
+ every { components } returns mockk {
+ every { backgroundServices.syncedTabsStorage } returns syncedTabsStorage
+ }
+ }
+ }
+
+ @Test
+ fun `starts and stops syncedTabsStorage on user authentication`() {
+ val observer = slot<AccountObserver>()
+ SyncedTabsIntegration(context, accountManager).launch()
+ verify { accountManager.register(capture(observer), owner = any(), autoPause = true) }
+
+ every { syncedTabsStorage.start() } just Runs
+ observer.captured.onAuthenticated(mockk(), mockk())
+ verify { syncedTabsStorage.start() }
+
+ every { syncedTabsStorage.stop() } just Runs
+ observer.captured.onLoggedOut()
+ verify { syncedTabsStorage.stop() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/sync/ext/SyncedDeviceTabsTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/sync/ext/SyncedDeviceTabsTest.kt
new file mode 100644
index 0000000000..acf66374bc
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/sync/ext/SyncedDeviceTabsTest.kt
@@ -0,0 +1,106 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.sync.ext
+
+import io.mockk.every
+import io.mockk.mockk
+import mozilla.components.browser.storage.sync.SyncedDeviceTabs
+import mozilla.components.browser.storage.sync.Tab
+import mozilla.components.browser.storage.sync.TabEntry
+import mozilla.components.concept.sync.DeviceType
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.mozilla.fenix.tabstray.ext.toComposeList
+import org.mozilla.fenix.tabstray.syncedtabs.SyncedTabsListItem
+
+class SyncedDeviceTabsTest {
+ private val noTabDevice = SyncedDeviceTabs(
+ device = mockk {
+ every { displayName } returns "Charcoal"
+ every { id } returns "123"
+ every { deviceType } returns DeviceType.DESKTOP
+ },
+ tabs = emptyList(),
+ )
+
+ private val oneTabDevice = SyncedDeviceTabs(
+ device = mockk {
+ every { displayName } returns "Charcoal"
+ every { id } returns "1234"
+ every { deviceType } returns DeviceType.DESKTOP
+ },
+ tabs = listOf(
+ Tab(
+ history = listOf(
+ TabEntry(
+ title = "Mozilla",
+ url = "https://mozilla.org",
+ iconUrl = null,
+ ),
+ ),
+ active = 0,
+ lastUsed = 0L,
+ inactive = false,
+ ),
+ ),
+ )
+
+ private val twoTabDevice = SyncedDeviceTabs(
+ device = mockk {
+ every { displayName } returns "Emerald"
+ every { id } returns "12345"
+ every { deviceType } returns DeviceType.MOBILE
+ },
+ tabs = listOf(
+ Tab(
+ history = listOf(
+ TabEntry(
+ title = "Mozilla",
+ url = "https://mozilla.org",
+ iconUrl = null,
+ ),
+ ),
+ active = 0,
+ lastUsed = 0L,
+ inactive = false,
+ ),
+ Tab(
+ history = listOf(
+ TabEntry(
+ title = "Firefox",
+ url = "https://firefox.com",
+ iconUrl = null,
+ ),
+ ),
+ active = 0,
+ lastUsed = 0L,
+ inactive = false,
+ ),
+ ),
+ )
+
+ @Test
+ fun `GIVEN two synced devices WHEN the compose list is generated THEN two device section is returned`() {
+ val syncedDeviceList = listOf(oneTabDevice, twoTabDevice)
+ val listData = syncedDeviceList.toComposeList()
+
+ assertEquals(2, listData.count())
+ assertTrue(listData[0] is SyncedTabsListItem.DeviceSection)
+ assertEquals(oneTabDevice.tabs.size, (listData[0] as SyncedTabsListItem.DeviceSection).tabs.size)
+ assertTrue(listData[1] is SyncedTabsListItem.DeviceSection)
+ assertEquals(twoTabDevice.tabs.size, (listData[1] as SyncedTabsListItem.DeviceSection).tabs.size)
+ }
+
+ @Test
+ fun `GIVEN one synced device with no tabs WHEN the compose list is generated THEN one device with an empty tabs list is returned`() {
+ val syncedDeviceList = listOf(noTabDevice)
+ val listData = syncedDeviceList.toComposeList()
+
+ assertEquals(1, listData.count())
+ assertTrue(listData[0] is SyncedTabsListItem.DeviceSection)
+ assertEquals(0, (listData[0] as SyncedTabsListItem.DeviceSection).tabs.size)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabhistory/TabHistoryAdapterTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabhistory/TabHistoryAdapterTest.kt
new file mode 100644
index 0000000000..4df1946116
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabhistory/TabHistoryAdapterTest.kt
@@ -0,0 +1,114 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabhistory
+
+import android.content.Context
+import android.widget.FrameLayout
+import androidx.appcompat.view.ContextThemeWrapper
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.browser.icons.BrowserIcons
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class TabHistoryAdapterTest {
+
+ @MockK
+ private lateinit var interactor: TabHistoryInteractor
+ private lateinit var context: Context
+ private lateinit var parent: FrameLayout
+ private lateinit var adapter: TabHistoryAdapter
+
+ private val selectedItem = TabHistoryItem(
+ title = "Mozilla",
+ url = "https://mozilla.org",
+ index = 0,
+ isSelected = true,
+ )
+ private val unselectedItem = TabHistoryItem(
+ title = "Firefox",
+ url = "https://firefox.com",
+ index = 1,
+ isSelected = false,
+ )
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ context = ContextThemeWrapper(testContext, R.style.NormalTheme)
+ parent = FrameLayout(context)
+ adapter = TabHistoryAdapter(interactor)
+ }
+
+ @Test
+ fun `creates and binds view holder`() {
+ every { testContext.components.core.icons } returns BrowserIcons(testContext, mockk(relaxed = true))
+ adapter.submitList(listOf(selectedItem, unselectedItem))
+
+ val holder = spyk(adapter.createViewHolder(parent, 0))
+
+ adapter.bindViewHolder(holder, 0)
+ verify { holder.bind(selectedItem) }
+
+ adapter.bindViewHolder(holder, 1)
+ verify { holder.bind(unselectedItem) }
+ }
+
+ @Test
+ fun `items are the same if they have matching URLs`() {
+ assertTrue(
+ TabHistoryAdapter.DiffCallback.areItemsTheSame(
+ selectedItem,
+ selectedItem,
+ ),
+ )
+ assertTrue(
+ TabHistoryAdapter.DiffCallback.areItemsTheSame(
+ unselectedItem,
+ unselectedItem.copy(title = "Waterbug", index = 2, isSelected = true),
+ ),
+ )
+ assertFalse(
+ TabHistoryAdapter.DiffCallback.areItemsTheSame(
+ unselectedItem,
+ unselectedItem.copy(url = "https://firefox.com/subpage"),
+ ),
+ )
+ }
+
+ @Test
+ fun `equal items have the same contents`() {
+ assertTrue(
+ TabHistoryAdapter.DiffCallback.areContentsTheSame(
+ selectedItem,
+ selectedItem,
+ ),
+ )
+ assertFalse(
+ TabHistoryAdapter.DiffCallback.areContentsTheSame(
+ selectedItem,
+ selectedItem.copy(title = "Waterbug", index = 2, isSelected = false),
+ ),
+ )
+ assertFalse(
+ TabHistoryAdapter.DiffCallback.areContentsTheSame(
+ unselectedItem,
+ selectedItem,
+ ),
+ )
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabhistory/TabHistoryControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabhistory/TabHistoryControllerTest.kt
new file mode 100644
index 0000000000..d2f08dcf5f
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabhistory/TabHistoryControllerTest.kt
@@ -0,0 +1,61 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabhistory
+
+import androidx.navigation.NavController
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.feature.session.SessionUseCases
+import org.junit.Before
+import org.junit.Test
+
+class TabHistoryControllerTest {
+
+ private lateinit var navController: NavController
+ private lateinit var goToHistoryIndexUseCase: SessionUseCases.GoToHistoryIndexUseCase
+ private lateinit var currentItem: TabHistoryItem
+ private lateinit var store: BrowserStore
+
+ @Before
+ fun setUp() {
+ store = BrowserStore()
+ navController = mockk(relaxed = true)
+ goToHistoryIndexUseCase = spyk(SessionUseCases(store).goToHistoryIndex)
+ currentItem = TabHistoryItem(
+ index = 0,
+ title = "",
+ url = "",
+ isSelected = true,
+ )
+ }
+
+ @Test
+ fun handleGoToHistoryIndexNormalBrowsing() {
+ val controller = DefaultTabHistoryController(
+ navController = navController,
+ goToHistoryIndexUseCase = goToHistoryIndexUseCase,
+ )
+
+ controller.handleGoToHistoryItem(currentItem)
+ verify { navController.navigateUp() }
+ verify { goToHistoryIndexUseCase.invoke(currentItem.index) }
+ }
+
+ @Test
+ fun handleGoToHistoryIndexCustomTab() {
+ val customTabId = "customTabId"
+ val customTabController = DefaultTabHistoryController(
+ navController = navController,
+ goToHistoryIndexUseCase = goToHistoryIndexUseCase,
+ customTabId = customTabId,
+ )
+
+ customTabController.handleGoToHistoryItem(currentItem)
+ verify { navController.navigateUp() }
+ verify { goToHistoryIndexUseCase.invoke(currentItem.index, customTabId) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabhistory/TabHistoryInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabhistory/TabHistoryInteractorTest.kt
new file mode 100644
index 0000000000..d9ffc5ca21
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabhistory/TabHistoryInteractorTest.kt
@@ -0,0 +1,24 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabhistory
+
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Test
+
+class TabHistoryInteractorTest {
+
+ val controller: TabHistoryController = mockk(relaxed = true)
+ val interactor = TabHistoryInteractor(controller)
+
+ @Test
+ fun onGoToHistoryItem() {
+ val item: TabHistoryItem = mockk()
+
+ interactor.goToHistoryItem(item)
+
+ verify { controller.handleGoToHistoryItem(item) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabhistory/TabHistoryViewHolderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabhistory/TabHistoryViewHolderTest.kt
new file mode 100644
index 0000000000..9bdeadbc24
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabhistory/TabHistoryViewHolderTest.kt
@@ -0,0 +1,84 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabhistory
+
+import android.view.View
+import io.mockk.CapturingSlot
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import mozilla.components.browser.icons.BrowserIcons
+import mozilla.components.browser.icons.IconRequest
+import mozilla.components.ui.widgets.WidgetSiteItemView
+import org.junit.Before
+import org.junit.Test
+
+class TabHistoryViewHolderTest {
+
+ @MockK(relaxed = true)
+ private lateinit var view: WidgetSiteItemView
+
+ @MockK private lateinit var interactor: TabHistoryViewInteractor
+
+ @MockK private lateinit var icons: BrowserIcons
+ private lateinit var holder: TabHistoryViewHolder
+ private lateinit var onClick: CapturingSlot<View.OnClickListener>
+
+ private val selectedItem = TabHistoryItem(
+ title = "Mozilla",
+ url = "https://mozilla.org",
+ index = 0,
+ isSelected = true,
+ )
+ private val unselectedItem = TabHistoryItem(
+ title = "Firefox",
+ url = "https://firefox.com",
+ index = 1,
+ isSelected = false,
+ )
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ onClick = slot()
+
+ every { view.setOnClickListener(capture(onClick)) } just Runs
+ every { icons.loadIntoView(view.iconView, any()) } returns mockk()
+
+ holder = TabHistoryViewHolder(view, interactor, icons)
+ }
+
+ @Test
+ fun `calls interactor on click`() {
+ every { interactor.goToHistoryItem(any()) } just Runs
+
+ val item = mockk<TabHistoryItem>(relaxed = true)
+ holder.bind(item)
+ onClick.captured.onClick(mockk())
+ verify { interactor.goToHistoryItem(item) }
+ }
+
+ @Test
+ fun `binds title and url`() {
+ holder.bind(unselectedItem)
+
+ verify { view.setText(label = "Firefox", caption = "https://firefox.com") }
+ verify { icons.loadIntoView(view.iconView, IconRequest("https://firefox.com")) }
+ }
+
+ @Test
+ fun `binds background`() {
+ holder.bind(selectedItem)
+ verify { view.setBackgroundColor(any()) }
+
+ holder.bind(unselectedItem)
+ verify { view.background = null }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/CloseOnLastTabBindingTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/CloseOnLastTabBindingTest.kt
new file mode 100644
index 0000000000..3832798e2a
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/CloseOnLastTabBindingTest.kt
@@ -0,0 +1,114 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray
+
+import io.mockk.Called
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Rule
+import org.junit.Test
+
+class CloseOnLastTabBindingTest {
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Test
+ fun `WHEN the binding starts THEN do nothing`() {
+ val browserStore = BrowserStore()
+ val tabsTrayStore = TabsTrayStore()
+ val interactor = mockk<NavigationInteractor>(relaxed = true)
+ val binding = CloseOnLastTabBinding(browserStore, tabsTrayStore, interactor)
+
+ binding.start()
+
+ verify { interactor wasNot Called }
+ }
+
+ @Test
+ fun `WHEN a tab is closed THEN invoke the interactor`() {
+ val browserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab(
+ "https://mozilla.org",
+ id = "tab1",
+ ),
+ ),
+ ),
+ )
+ val tabsTrayStore = TabsTrayStore()
+ val interactor = mockk<NavigationInteractor>(relaxed = true)
+ val binding = CloseOnLastTabBinding(browserStore, tabsTrayStore, interactor)
+
+ binding.start()
+
+ browserStore.dispatch(TabListAction.RemoveTabAction("tab1"))
+
+ browserStore.waitUntilIdle()
+
+ verify { interactor.onCloseAllTabsClicked(false) }
+ }
+
+ @Test
+ fun `WHEN a private tab is closed THEN invoke the interactor`() {
+ val browserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab(
+ "https://mozilla.org",
+ id = "tab1",
+ private = true,
+ ),
+ ),
+ ),
+ )
+ val tabsTrayStore = TabsTrayStore(TabsTrayState(selectedPage = Page.PrivateTabs))
+ val interactor = mockk<NavigationInteractor>(relaxed = true)
+ val binding = CloseOnLastTabBinding(browserStore, tabsTrayStore, interactor)
+
+ binding.start()
+
+ browserStore.dispatch(TabListAction.RemoveTabAction("tab1"))
+
+ browserStore.waitUntilIdle()
+
+ verify { interactor.onCloseAllTabsClicked(true) }
+ }
+
+ @Test
+ fun `WHEN on the synced tabs page THEN nothing is invoked`() {
+ val browserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab(
+ "https://mozilla.org",
+ id = "tab1",
+ private = true,
+ ),
+ ),
+ ),
+ )
+ val tabsTrayStore = TabsTrayStore(TabsTrayState(selectedPage = Page.SyncedTabs))
+ val interactor = mockk<NavigationInteractor>(relaxed = true)
+ val binding = CloseOnLastTabBinding(browserStore, tabsTrayStore, interactor)
+
+ binding.start()
+
+ browserStore.dispatch(TabListAction.RemoveAllTabsAction())
+
+ browserStore.waitUntilIdle()
+
+ verify { interactor wasNot Called }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/DefaultTabsTrayControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/DefaultTabsTrayControllerTest.kt
new file mode 100644
index 0000000000..3f522dea41
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/DefaultTabsTrayControllerTest.kt
@@ -0,0 +1,1167 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray
+
+import androidx.navigation.NavController
+import androidx.navigation.NavDirections
+import androidx.navigation.NavOptions
+import io.mockk.MockKAnnotations
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.runs
+import io.mockk.spyk
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import io.mockk.verifyOrder
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.selector.findTab
+import mozilla.components.browser.state.selector.getNormalOrPrivateTabs
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.ContentState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.content.DownloadState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.browser.storage.sync.Tab
+import mozilla.components.browser.storage.sync.TabEntry
+import mozilla.components.concept.base.profiler.Profiler
+import mozilla.components.feature.tabs.TabsUseCases
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.middleware.CaptureActionsMiddleware
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.RuleChain
+import org.junit.runner.RunWith
+import org.mozilla.fenix.BrowserDirection
+import org.mozilla.fenix.GleanMetrics.Collections
+import org.mozilla.fenix.GleanMetrics.Events
+import org.mozilla.fenix.GleanMetrics.TabsTray
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.R
+import org.mozilla.fenix.browser.browsingmode.BrowsingMode
+import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
+import org.mozilla.fenix.browser.browsingmode.DefaultBrowsingModeManager
+import org.mozilla.fenix.collections.CollectionsDialog
+import org.mozilla.fenix.collections.show
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.TabCollectionStorage
+import org.mozilla.fenix.components.appstate.AppAction
+import org.mozilla.fenix.components.bookmarks.BookmarksUseCase
+import org.mozilla.fenix.ext.maxActiveTime
+import org.mozilla.fenix.ext.potentialInactiveTabs
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.home.HomeFragment
+import org.mozilla.fenix.library.bookmarks.BookmarksSharedViewModel
+import org.mozilla.fenix.utils.Settings
+import java.util.concurrent.TimeUnit
+
+@RunWith(FenixRobolectricTestRunner::class) // for gleanTestRule
+class DefaultTabsTrayControllerTest {
+ @MockK(relaxed = true)
+ private lateinit var trayStore: TabsTrayStore
+
+ @MockK(relaxed = true)
+ private lateinit var browserStore: BrowserStore
+
+ @MockK(relaxed = true)
+ private lateinit var browsingModeManager: BrowsingModeManager
+
+ @MockK(relaxed = true)
+ private lateinit var navController: NavController
+
+ @MockK(relaxed = true)
+ private lateinit var profiler: Profiler
+
+ @MockK(relaxed = true)
+ private lateinit var navigationInteractor: NavigationInteractor
+
+ @MockK(relaxed = true)
+ private lateinit var tabsUseCases: TabsUseCases
+
+ @MockK(relaxed = true)
+ private lateinit var activity: HomeActivity
+
+ private val appStore: AppStore = mockk(relaxed = true)
+ private val settings: Settings = mockk(relaxed = true)
+
+ private val bookmarksUseCase: BookmarksUseCase = mockk(relaxed = true)
+ private val collectionStorage: TabCollectionStorage = mockk(relaxed = true)
+
+ private val bookmarksSharedViewModel: BookmarksSharedViewModel = mockk(relaxed = true)
+
+ private val coroutinesTestRule: MainCoroutineRule = MainCoroutineRule()
+ private val testDispatcher = coroutinesTestRule.testDispatcher
+
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @get:Rule
+ val chain: RuleChain = RuleChain.outerRule(gleanTestRule).around(coroutinesTestRule)
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ }
+
+ @Test
+ fun `GIVEN private mode WHEN the fab is clicked THEN a profile marker is added for the operations executed`() {
+ profiler = spyk(profiler) {
+ every { getProfilerTime() } returns Double.MAX_VALUE
+ }
+
+ assertNull(TabsTray.newPrivateTabTapped.testGetValue())
+
+ createController().handlePrivateTabsFabClick()
+
+ assertNotNull(TabsTray.newPrivateTabTapped.testGetValue())
+
+ verifyOrder {
+ profiler.getProfilerTime()
+ navController.navigate(
+ TabsTrayFragmentDirections.actionGlobalHome(focusOnAddressBar = true),
+ )
+ navigationInteractor.onTabTrayDismissed()
+ profiler.addMarker(
+ "DefaultTabTrayController.onNewTabTapped",
+ Double.MAX_VALUE,
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN normal mode WHEN the fab is clicked THEN a profile marker is added for the operations executed`() {
+ profiler = spyk(profiler) {
+ every { getProfilerTime() } returns Double.MAX_VALUE
+ }
+
+ createController().handleNormalTabsFabClick()
+
+ verifyOrder {
+ profiler.getProfilerTime()
+ navController.navigate(
+ TabsTrayFragmentDirections.actionGlobalHome(focusOnAddressBar = true),
+ )
+ navigationInteractor.onTabTrayDismissed()
+ profiler.addMarker(
+ "DefaultTabTrayController.onNewTabTapped",
+ Double.MAX_VALUE,
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN private mode WHEN the fab is clicked THEN Event#NewPrivateTabTapped is added to telemetry`() {
+ assertNull(TabsTray.newPrivateTabTapped.testGetValue())
+
+ createController().handlePrivateTabsFabClick()
+
+ assertNotNull(TabsTray.newPrivateTabTapped.testGetValue())
+ }
+
+ @Test
+ fun `GIVEN normal mode WHEN the fab is clicked THEN Event#NewTabTapped is added to telemetry`() {
+ assertNull(TabsTray.newTabTapped.testGetValue())
+
+ createController().handleNormalTabsFabClick()
+
+ assertNotNull(TabsTray.newTabTapped.testGetValue())
+ }
+
+ @Test
+ fun `GIVEN the user is on the synced tabs page WHEN the fab is clicked THEN fire off a sync action`() {
+ every { trayStore.state.syncing } returns false
+
+ createController().handleSyncedTabsFabClick()
+
+ verify { trayStore.dispatch(TabsTrayAction.SyncNow) }
+ }
+
+ @Test
+ fun `GIVEN the user is on the synced tabs page and there is already an active sync WHEN the fab is clicked THEN no action should be taken`() {
+ every { trayStore.state.syncing } returns true
+
+ createController().handleSyncedTabsFabClick()
+
+ verify(exactly = 0) { trayStore.dispatch(TabsTrayAction.SyncNow) }
+ }
+
+ @Test
+ fun `WHEN handleTabDeletion is called THEN Event#ClosedExistingTab is added to telemetry`() {
+ val tab: TabSessionState = mockk { every { content.private } returns true }
+ assertNull(TabsTray.closedExistingTab.testGetValue())
+
+ every { browserStore.state } returns mockk()
+ try {
+ mockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
+ every { browserStore.state.findTab(any()) } returns tab
+ every { browserStore.state.getNormalOrPrivateTabs(any()) } returns listOf(tab)
+
+ createController().handleTabDeletion("testTabId", "unknown")
+ assertNotNull(TabsTray.closedExistingTab.testGetValue())
+ } finally {
+ unmockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
+ }
+ }
+
+ @Test
+ fun `GIVEN active private download WHEN handleTabDeletion is called for the last private tab THEN showCancelledDownloadWarning is called`() {
+ var showCancelledDownloadWarningInvoked = false
+ val controller = spyk(
+ createController(
+ showCancelledDownloadWarning = { _, _, _ ->
+ showCancelledDownloadWarningInvoked = true
+ },
+ ),
+ )
+ val tab: TabSessionState = mockk { every { content.private } returns true }
+ every { browserStore.state } returns mockk()
+ every { browserStore.state.downloads } returns mapOf(
+ "1" to DownloadState(
+ "https://mozilla.org/download",
+ private = true,
+ destinationDirectory = "Download",
+ status = DownloadState.Status.DOWNLOADING,
+ ),
+ )
+ try {
+ mockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
+ every { browserStore.state.findTab(any()) } returns tab
+ every { browserStore.state.getNormalOrPrivateTabs(any()) } returns listOf(tab)
+ every { browserStore.state.selectedTabId } returns "testTabId"
+
+ controller.handleTabDeletion("testTabId", "unknown")
+
+ assertTrue(showCancelledDownloadWarningInvoked)
+ } finally {
+ unmockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
+ }
+ }
+
+ @Test
+ fun `WHEN handleTrayScrollingToPosition is called with smoothScroll=true THEN it scrolls to that position with smoothScroll`() {
+ var selectTabPositionInvoked = false
+ createController(
+ selectTabPosition = { position, smoothScroll ->
+ assertEquals(3, position)
+ assertTrue(smoothScroll)
+ selectTabPositionInvoked = true
+ },
+ ).handleTrayScrollingToPosition(3, true)
+
+ assertTrue(selectTabPositionInvoked)
+ }
+
+ @Test
+ fun `WHEN handleTrayScrollingToPosition is called with smoothScroll=true THEN it emits an action for the tray page of that tab position`() {
+ createController().handleTrayScrollingToPosition(33, true)
+
+ verify { trayStore.dispatch(TabsTrayAction.PageSelected(Page.positionToPage(33))) }
+ }
+
+ @Test
+ fun `WHEN handleTrayScrollingToPosition is called with smoothScroll=false THEN it emits an action for the tray page of that tab position`() {
+ createController().handleTrayScrollingToPosition(44, true)
+
+ verify { trayStore.dispatch(TabsTrayAction.PageSelected(Page.positionToPage(44))) }
+ }
+
+ @Test
+ fun `GIVEN already on browserFragment WHEN handleNavigateToBrowser is called THEN the tray is dismissed`() {
+ every { navController.currentDestination?.id } returns R.id.browserFragment
+
+ var dismissTrayInvoked = false
+ createController(dismissTray = { dismissTrayInvoked = true }).handleNavigateToBrowser()
+
+ assertTrue(dismissTrayInvoked)
+ verify(exactly = 0) { navController.popBackStack() }
+ verify(exactly = 0) { navController.popBackStack(any<Int>(), any()) }
+ verify(exactly = 0) { navController.navigate(any<Int>()) }
+ verify(exactly = 0) { navController.navigate(any<NavDirections>()) }
+ verify(exactly = 0) { navController.navigate(any<NavDirections>(), any<NavOptions>()) }
+ }
+
+ @Test
+ fun `GIVEN not already on browserFragment WHEN handleNavigateToBrowser is called THEN the tray is dismissed and popBackStack is executed`() {
+ every { navController.currentDestination?.id } returns R.id.browserFragment + 1
+ every { navController.popBackStack(R.id.browserFragment, false) } returns true
+
+ var dismissTrayInvoked = false
+ createController(dismissTray = { dismissTrayInvoked = true }).handleNavigateToBrowser()
+
+ assertTrue(dismissTrayInvoked)
+ verify { navController.popBackStack(R.id.browserFragment, false) }
+ verify(exactly = 0) { navController.navigate(any<Int>()) }
+ verify(exactly = 0) { navController.navigate(any<NavDirections>()) }
+ verify(exactly = 0) { navController.navigate(any<NavDirections>(), any<NavOptions>()) }
+ }
+
+ @Test
+ fun `GIVEN not already on browserFragment WHEN handleNavigateToBrowser is called and popBackStack fails THEN it navigates to browserFragment`() {
+ every { navController.currentDestination?.id } returns R.id.browserFragment + 1
+ every { navController.popBackStack(R.id.browserFragment, false) } returns false
+
+ var dismissTrayInvoked = false
+ createController(dismissTray = { dismissTrayInvoked = true }).handleNavigateToBrowser()
+
+ assertTrue(dismissTrayInvoked)
+ verify { navController.popBackStack(R.id.browserFragment, false) }
+ verify { navController.navigate(R.id.browserFragment) }
+ }
+
+ @Test
+ fun `GIVEN not already on browserFragment WHEN handleNavigateToBrowser is called and popBackStack succeeds THEN the method finishes`() {
+ every { navController.popBackStack(R.id.browserFragment, false) } returns true
+
+ var dismissTrayInvoked = false
+ createController(dismissTray = { dismissTrayInvoked = true }).handleNavigateToBrowser()
+
+ assertTrue(dismissTrayInvoked)
+ verify(exactly = 1) { navController.popBackStack(R.id.browserFragment, false) }
+ verify(exactly = 0) { navController.navigate(R.id.browserFragment) }
+ }
+
+ @Test
+ fun `GIVEN more tabs opened WHEN handleTabDeletion is called THEN that tab is removed and an undo snackbar is shown`() {
+ val tab: TabSessionState = mockk {
+ every { content } returns mockk()
+ every { content.private } returns true
+ }
+ every { browserStore.state } returns mockk()
+ try {
+ mockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
+ every { browserStore.state.findTab(any()) } returns tab
+ every { browserStore.state.getNormalOrPrivateTabs(any()) } returns listOf(tab, mockk())
+
+ var showUndoSnackbarForTabInvoked = false
+ createController(
+ showUndoSnackbarForTab = {
+ assertTrue(it)
+ showUndoSnackbarForTabInvoked = true
+ },
+ ).handleTabDeletion("22")
+
+ verify { tabsUseCases.removeTab("22") }
+ assertTrue(showUndoSnackbarForTabInvoked)
+ } finally {
+ unmockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
+ }
+ }
+
+ @Test
+ fun `GIVEN only one tab opened WHEN handleTabDeletion is called THEN that it navigates to home where the tab will be removed`() {
+ var showUndoSnackbarForTabInvoked = false
+ val controller = spyk(createController(showUndoSnackbarForTab = { showUndoSnackbarForTabInvoked = true }))
+ val tab: TabSessionState = mockk {
+ every { content } returns mockk()
+ every { content.private } returns true
+ }
+ every { browserStore.state } returns mockk()
+ try {
+ val testTabId = "33"
+ mockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
+ every { browserStore.state.findTab(any()) } returns tab
+ every { browserStore.state.getNormalOrPrivateTabs(any()) } returns listOf(tab)
+ every { browserStore.state.selectedTabId } returns testTabId
+
+ controller.handleTabDeletion(testTabId)
+
+ verify { controller.dismissTabsTrayAndNavigateHome(testTabId) }
+ verify(exactly = 0) { tabsUseCases.removeTab(any()) }
+ assertFalse(showUndoSnackbarForTabInvoked)
+ } finally {
+ unmockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
+ }
+ }
+
+ @Test
+ fun `WHEN handleMultipleTabsDeletion is called to close all private tabs THEN that it navigates to home where that tabs will be removed and shows undo snackbar`() {
+ var showUndoSnackbarForTabInvoked = false
+ val controller = spyk(
+ createController(
+ showUndoSnackbarForTab = {
+ assertTrue(it)
+ showUndoSnackbarForTabInvoked = true
+ },
+ ),
+ )
+
+ val privateTab = createTab(url = "url", private = true)
+
+ every { browserStore.state } returns mockk()
+ try {
+ mockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
+ every { browserStore.state.getNormalOrPrivateTabs(any()) } returns listOf(mockk(), mockk())
+
+ controller.deleteMultipleTabs(listOf(privateTab, mockk()))
+
+ verify { controller.dismissTabsTrayAndNavigateHome(HomeFragment.ALL_PRIVATE_TABS) }
+ assertTrue(showUndoSnackbarForTabInvoked)
+ verify(exactly = 0) { tabsUseCases.removeTabs(any()) }
+ } finally {
+ unmockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
+ }
+ }
+
+ @Test
+ fun `WHEN handleMultipleTabsDeletion is called to close all normal tabs THEN that it navigates to home where that tabs will be removed and shows undo snackbar`() {
+ var showUndoSnackbarForTabInvoked = false
+ val controller = spyk(
+ createController(
+ showUndoSnackbarForTab = {
+ assertFalse(it)
+ showUndoSnackbarForTabInvoked = true
+ },
+ ),
+ )
+
+ val normalTab = createTab(url = "url", private = false)
+
+ every { browserStore.state } returns mockk()
+ try {
+ mockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
+ every { browserStore.state.getNormalOrPrivateTabs(any()) } returns listOf(mockk(), mockk())
+
+ controller.deleteMultipleTabs(listOf(normalTab, normalTab))
+
+ verify { controller.dismissTabsTrayAndNavigateHome(HomeFragment.ALL_NORMAL_TABS) }
+ verify(exactly = 0) { tabsUseCases.removeTabs(any()) }
+ assertTrue(showUndoSnackbarForTabInvoked)
+ } finally {
+ unmockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
+ }
+ }
+
+ @Test
+ fun `WHEN handleMultipleTabsDeletion is called to close some private tabs THEN that it uses tabsUseCases#removeTabs and shows an undo snackbar`() {
+ var showUndoSnackbarForTabInvoked = false
+ val controller = spyk(createController(showUndoSnackbarForTab = { showUndoSnackbarForTabInvoked = true }))
+ val privateTab = createTab(id = "42", url = "url", private = true)
+
+ every { browserStore.state } returns mockk()
+ try {
+ mockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
+ every { browserStore.state.getNormalOrPrivateTabs(any()) } returns listOf(mockk(), mockk())
+
+ controller.deleteMultipleTabs(listOf(privateTab))
+
+ verify { tabsUseCases.removeTabs(listOf("42")) }
+ verify(exactly = 0) { controller.dismissTabsTrayAndNavigateHome(any()) }
+ assertTrue(showUndoSnackbarForTabInvoked)
+ } finally {
+ unmockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
+ }
+ }
+
+ @Test
+ fun `WHEN handleMultipleTabsDeletion is called to close some normal tabs THEN that it uses tabsUseCases#removeTabs and shows an undo snackbar`() {
+ var showUndoSnackbarForTabInvoked = false
+ val controller = spyk(createController(showUndoSnackbarForTab = { showUndoSnackbarForTabInvoked = true }))
+ val privateTab = createTab(id = "24", url = "url", private = false)
+
+ every { browserStore.state } returns mockk()
+ try {
+ mockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
+ every { browserStore.state.getNormalOrPrivateTabs(any()) } returns listOf(mockk(), mockk())
+
+ controller.deleteMultipleTabs(listOf(privateTab))
+
+ verify { tabsUseCases.removeTabs(listOf("24")) }
+ verify(exactly = 0) { controller.dismissTabsTrayAndNavigateHome(any()) }
+ assertTrue(showUndoSnackbarForTabInvoked)
+ } finally {
+ unmockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
+ }
+ }
+
+ @Test
+ fun `GIVEN one tab is selected WHEN the delete selected tabs button is clicked THEN report the telemetry and delete the tabs`() {
+ val controller = spyk(createController())
+
+ every { trayStore.state.mode.selectedTabs } returns setOf(createTab(url = "url"))
+ every { controller.deleteMultipleTabs(any()) } just runs
+
+ controller.handleDeleteSelectedTabsClicked()
+
+ assertNotNull(TabsTray.closeSelectedTabs.testGetValue())
+ val snapshot = TabsTray.closeSelectedTabs.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("1", snapshot.single().extra?.getValue("tab_count"))
+
+ verify { trayStore.dispatch(TabsTrayAction.ExitSelectMode) }
+ }
+
+ @Test
+ fun `GIVEN private mode selected WHEN sendNewTabEvent is called THEN NewPrivateTabTapped is tracked in telemetry`() {
+ createController().sendNewTabEvent(true)
+
+ assertNotNull(TabsTray.newPrivateTabTapped.testGetValue())
+ }
+
+ @Test
+ fun `GIVEN normal mode selected WHEN sendNewTabEvent is called THEN NewTabTapped is tracked in telemetry`() {
+ assertNull(TabsTray.newTabTapped.testGetValue())
+
+ createController().sendNewTabEvent(false)
+
+ assertNotNull(TabsTray.newTabTapped.testGetValue())
+ }
+
+ @Test
+ fun `WHEN dismissTabsTrayAndNavigateHome is called with a specific tab id THEN tray is dismissed and navigates home is opened to delete that tab`() {
+ var dismissTrayInvoked = false
+ var navigateToHomeAndDeleteSessionInvoked = false
+ createController(
+ dismissTray = {
+ dismissTrayInvoked = true
+ },
+ navigateToHomeAndDeleteSession = {
+ assertEquals("randomId", it)
+ navigateToHomeAndDeleteSessionInvoked = true
+ },
+ ).dismissTabsTrayAndNavigateHome("randomId")
+
+ assertTrue(dismissTrayInvoked)
+ assertTrue(navigateToHomeAndDeleteSessionInvoked)
+ }
+
+ @Test
+ fun `WHEN a synced tab is clicked THEN the metrics are reported and the tab is opened`() {
+ val tab = mockk<Tab>()
+ val entry = mockk<TabEntry>()
+ assertNull(Events.syncedTabOpened.testGetValue())
+
+ every { tab.active() }.answers { entry }
+ every { entry.url }.answers { "https://mozilla.org" }
+
+ var dismissTabTrayInvoked = false
+ createController(
+ dismissTray = {
+ dismissTabTrayInvoked = true
+ },
+ ).handleSyncedTabClicked(tab)
+
+ assertTrue(dismissTabTrayInvoked)
+ assertNotNull(Events.syncedTabOpened.testGetValue())
+
+ verify {
+ activity.openToBrowserAndLoad(
+ searchTermOrURL = "https://mozilla.org",
+ newTab = true,
+ from = BrowserDirection.FromTabsTray,
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN no tabs selected and the user is not in multi select mode WHEN the user long taps a tab THEN that tab will become selected`() {
+ trayStore = TabsTrayStore()
+ val controller = spyk(createController())
+ val tab1 = TabSessionState(
+ id = "1",
+ content = ContentState(
+ url = "www.mozilla.com",
+ ),
+ )
+ val tab2 = TabSessionState(
+ id = "2",
+ content = ContentState(
+ url = "www.google.com",
+ ),
+ )
+ trayStore.dispatch(TabsTrayAction.ExitSelectMode)
+ trayStore.waitUntilIdle()
+
+ controller.handleTabSelected(tab1, "Tabs tray")
+ verify(exactly = 1) { controller.handleTabSelected(tab1, "Tabs tray") }
+
+ controller.handleTabSelected(tab2, "Tabs tray")
+ verify(exactly = 1) { controller.handleTabSelected(tab2, "Tabs tray") }
+ }
+
+ @Test
+ fun `GIVEN the user is in multi select mode and a tab is selected WHEN the user taps the selected tab THEN the tab will become unselected`() {
+ trayStore = TabsTrayStore()
+ val tab1 = TabSessionState(
+ id = "1",
+ content = ContentState(
+ url = "www.mozilla.com",
+ ),
+ )
+ val tab2 = TabSessionState(
+ id = "2",
+ content = ContentState(
+ url = "www.google.com",
+ ),
+ )
+ val controller = spyk(createController())
+ trayStore.dispatch(TabsTrayAction.EnterSelectMode)
+ trayStore.dispatch(TabsTrayAction.AddSelectTab(tab1))
+ trayStore.dispatch(TabsTrayAction.AddSelectTab(tab2))
+ trayStore.waitUntilIdle()
+
+ controller.handleTabSelected(tab1, "Tabs tray")
+ verify(exactly = 1) { controller.handleTabUnselected(tab1) }
+
+ controller.handleTabSelected(tab2, "Tabs tray")
+ verify(exactly = 1) { controller.handleTabUnselected(tab2) }
+ }
+
+ @Test
+ fun `GIVEN at least a tab is selected and the user is in multi select mode WHEN the user taps a tab THEN that tab will become selected`() {
+ val middleware = CaptureActionsMiddleware<TabsTrayState, TabsTrayAction>()
+ trayStore = TabsTrayStore(middlewares = listOf(middleware))
+ trayStore.dispatch(TabsTrayAction.EnterSelectMode)
+ trayStore.waitUntilIdle()
+ val controller = spyk(createController())
+ val tab1 = TabSessionState(
+ id = "1",
+ content = ContentState(
+ url = "www.mozilla.com",
+ ),
+ )
+ val tab2 = TabSessionState(
+ id = "2",
+ content = ContentState(
+ url = "www.google.com",
+ ),
+ )
+
+ trayStore.dispatch(TabsTrayAction.EnterSelectMode)
+ trayStore.dispatch(TabsTrayAction.AddSelectTab(tab1))
+ trayStore.waitUntilIdle()
+
+ controller.handleTabSelected(tab2, "Tabs tray")
+
+ middleware.assertLastAction(TabsTrayAction.AddSelectTab::class) {
+ assertEquals(tab2, it.tab)
+ }
+ }
+
+ @Test
+ fun `GIVEN at least a tab is selected and the user is in multi select mode WHEN the user taps an inactive tab THEN that tab will not be selected`() {
+ val middleware = CaptureActionsMiddleware<TabsTrayState, TabsTrayAction>()
+ trayStore = TabsTrayStore(middlewares = listOf(middleware))
+ trayStore.dispatch(TabsTrayAction.EnterSelectMode)
+ trayStore.waitUntilIdle()
+ val controller = spyk(createController())
+ val normalTab = TabSessionState(
+ id = "1",
+ content = ContentState(
+ url = "www.mozilla.com",
+ ),
+ )
+ val inactiveTab = TabSessionState(
+ id = "2",
+ content = ContentState(
+ url = "www.google.com",
+ ),
+ )
+
+ trayStore.dispatch(TabsTrayAction.EnterSelectMode)
+ trayStore.dispatch(TabsTrayAction.AddSelectTab(normalTab))
+ trayStore.waitUntilIdle()
+
+ controller.handleTabSelected(inactiveTab, TrayPagerAdapter.INACTIVE_TABS_FEATURE_NAME)
+
+ middleware.assertLastAction(TabsTrayAction.AddSelectTab::class) {
+ assertEquals(normalTab, it.tab)
+ }
+ }
+
+ @Test
+ fun `GIVEN the user selects only the current tab WHEN the user forces tab to be inactive THEN tab does not become inactive`() {
+ val currentTab = TabSessionState(content = mockk(), id = "currentTab", createdAt = 11)
+ val secondTab = TabSessionState(content = mockk(), id = "secondTab", createdAt = 22)
+ browserStore = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(currentTab, secondTab),
+ selectedTabId = currentTab.id,
+ ),
+ )
+
+ every { trayStore.state.mode.selectedTabs } returns setOf(currentTab)
+
+ createController().handleForceSelectedTabsAsInactiveClicked(numDays = 5)
+
+ browserStore.waitUntilIdle()
+
+ val updatedCurrentTab = browserStore.state.tabs.first { it.id == currentTab.id }
+ assertEquals(updatedCurrentTab, currentTab)
+ val updatedSecondTab = browserStore.state.tabs.first { it.id == secondTab.id }
+ assertEquals(updatedSecondTab, secondTab)
+ }
+
+ @Test
+ fun `GIVEN the user selects multiple tabs including the current tab WHEN the user forces them all to be inactive THEN all but current tab become inactive`() {
+ val currentTab = TabSessionState(content = mockk(), id = "currentTab", createdAt = 11)
+ val secondTab = TabSessionState(content = mockk(), id = "secondTab", createdAt = 22)
+ browserStore = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(currentTab, secondTab),
+ selectedTabId = currentTab.id,
+ ),
+ )
+
+ every { trayStore.state.mode.selectedTabs } returns setOf(currentTab, secondTab)
+
+ createController().handleForceSelectedTabsAsInactiveClicked(numDays = 5)
+
+ browserStore.waitUntilIdle()
+
+ val updatedCurrentTab = browserStore.state.tabs.first { it.id == currentTab.id }
+ assertEquals(updatedCurrentTab, currentTab)
+ val updatedSecondTab = browserStore.state.tabs.first { it.id == secondTab.id }
+ assertNotEquals(updatedSecondTab, secondTab)
+ val expectedTime = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(5)
+ // Account for System.currentTimeMillis() giving different values in test vs the system under test
+ // and also for the waitUntilIdle to block for even hundreds of milliseconds.
+ assertTrue(updatedSecondTab.lastAccess in (expectedTime - 5000)..expectedTime)
+ assertTrue(updatedSecondTab.createdAt in (expectedTime - 5000)..expectedTime)
+ }
+
+ @Test
+ fun `GIVEN no value is provided for inactive days WHEN forcing tabs as inactive THEN set their last active time 15 days ago and exit multi selection`() {
+ val controller = spyk(createController())
+ every { trayStore.state.mode.selectedTabs } returns setOf(createTab(url = "https://mozilla.org"))
+ every { browserStore.state.selectedTabId } returns "test"
+
+ controller.handleForceSelectedTabsAsInactiveClicked()
+
+ verify { controller.handleForceSelectedTabsAsInactiveClicked(numDays = 15L) }
+
+ verify { trayStore.dispatch(TabsTrayAction.ExitSelectMode) }
+ }
+
+ fun `WHEN the inactive tabs section is expanded THEN the expanded telemetry event should be reported`() {
+ val controller = createController()
+
+ assertNull(TabsTray.inactiveTabsExpanded.testGetValue())
+ assertNull(TabsTray.inactiveTabsCollapsed.testGetValue())
+
+ controller.handleInactiveTabsHeaderClicked(expanded = true)
+
+ assertNotNull(TabsTray.inactiveTabsExpanded.testGetValue())
+ assertNull(TabsTray.inactiveTabsCollapsed.testGetValue())
+ }
+
+ @Test
+ fun `WHEN the inactive tabs section is collapsed THEN the collapsed telemetry event should be reported`() {
+ val controller = createController()
+
+ assertNull(TabsTray.inactiveTabsExpanded.testGetValue())
+ assertNull(TabsTray.inactiveTabsCollapsed.testGetValue())
+
+ controller.handleInactiveTabsHeaderClicked(expanded = false)
+
+ assertNull(TabsTray.inactiveTabsExpanded.testGetValue())
+ assertNotNull(TabsTray.inactiveTabsCollapsed.testGetValue())
+ }
+
+ @Test
+ fun `WHEN the inactive tabs auto-close feature prompt is dismissed THEN update settings and report the telemetry event`() {
+ val controller = spyk(createController())
+
+ assertNull(TabsTray.autoCloseDimissed.testGetValue())
+
+ controller.handleInactiveTabsAutoCloseDialogDismiss()
+
+ assertNotNull(TabsTray.autoCloseDimissed.testGetValue())
+ verify { settings.hasInactiveTabsAutoCloseDialogBeenDismissed = true }
+ }
+
+ @Test
+ fun `WHEN the inactive tabs auto-close feature prompt is accepted THEN update settings and report the telemetry event`() {
+ val controller = spyk(createController())
+
+ assertNull(TabsTray.autoCloseTurnOnClicked.testGetValue())
+
+ controller.handleEnableInactiveTabsAutoCloseClicked()
+
+ assertNotNull(TabsTray.autoCloseTurnOnClicked.testGetValue())
+
+ verify { settings.closeTabsAfterOneMonth = true }
+ verify { settings.closeTabsAfterOneWeek = false }
+ verify { settings.closeTabsAfterOneDay = false }
+ verify { settings.manuallyCloseTabs = false }
+ verify { settings.hasInactiveTabsAutoCloseDialogBeenDismissed = true }
+ }
+
+ @Test
+ fun `WHEN an inactive tab is selected THEN report the telemetry event and open the tab`() {
+ val controller = spyk(createController())
+ val tab = TabSessionState(
+ id = "tabId",
+ content = ContentState(
+ url = "www.mozilla.com",
+ ),
+ )
+
+ every { controller.handleTabSelected(any(), any()) } just runs
+
+ assertNull(TabsTray.openInactiveTab.testGetValue())
+
+ controller.handleInactiveTabClicked(tab)
+
+ assertNotNull(TabsTray.openInactiveTab.testGetValue())
+
+ verify { controller.handleTabSelected(tab, TrayPagerAdapter.INACTIVE_TABS_FEATURE_NAME) }
+ }
+
+ @Test
+ fun `WHEN an inactive tab is closed THEN report the telemetry event and delete the tab`() {
+ val controller = spyk(createController())
+ val tab = TabSessionState(
+ id = "tabId",
+ content = ContentState(
+ url = "www.mozilla.com",
+ ),
+ )
+
+ every { controller.handleTabDeletion(any(), any()) } just runs
+
+ assertNull(TabsTray.closeInactiveTab.testGetValue())
+
+ controller.handleCloseInactiveTabClicked(tab)
+
+ assertNotNull(TabsTray.closeInactiveTab.testGetValue())
+
+ verify { controller.handleTabDeletion(tab.id, TrayPagerAdapter.INACTIVE_TABS_FEATURE_NAME) }
+ }
+
+ @Test
+ fun `WHEN all inactive tabs are closed THEN perform the deletion and report the telemetry event and show a Snackbar`() {
+ var showSnackbarInvoked = false
+ val controller = createController(
+ showUndoSnackbarForTab = {
+ showSnackbarInvoked = true
+ },
+ )
+ val inactiveTab: TabSessionState = mockk {
+ every { lastAccess } returns maxActiveTime
+ every { createdAt } returns 0
+ every { id } returns "24"
+ every { content } returns mockk {
+ every { private } returns false
+ }
+ }
+
+ try {
+ mockkStatic("org.mozilla.fenix.ext.BrowserStateKt")
+ every { browserStore.state.potentialInactiveTabs } returns listOf(inactiveTab)
+ assertNull(TabsTray.closeAllInactiveTabs.testGetValue())
+
+ controller.handleDeleteAllInactiveTabsClicked()
+
+ verify { tabsUseCases.removeTabs(listOf("24")) }
+ assertNotNull(TabsTray.closeAllInactiveTabs.testGetValue())
+ assertTrue(showSnackbarInvoked)
+ } finally {
+ unmockkStatic("org.mozilla.fenix.ext.BrowserStateKt")
+ }
+ }
+
+ fun `WHEN a tab is selected THEN report the metric, update the state, and open the browser`() {
+ val controller = spyk(createController())
+ val tab = TabSessionState(
+ id = "tabId",
+ content = ContentState(
+ url = "www.mozilla.com",
+ ),
+ )
+ val source = TrayPagerAdapter.INACTIVE_TABS_FEATURE_NAME
+
+ every { controller.handleNavigateToBrowser() } just runs
+
+ assertNull(TabsTray.openedExistingTab.testGetValue())
+
+ controller.handleTabSelected(tab, source)
+
+ assertNotNull(TabsTray.openedExistingTab.testGetValue())
+ val snapshot = TabsTray.openedExistingTab.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals(source, snapshot.single().extra?.getValue("opened_existing_tab"))
+
+ verify { tabsUseCases.selectTab(tab.id) }
+ verify { controller.handleNavigateToBrowser() }
+ }
+
+ fun `WHEN a tab is selected without a source THEN report the metric with an unknown source, update the state, and open the browser`() {
+ val controller = spyk(createController())
+ val tab = TabSessionState(
+ id = "tabId",
+ content = ContentState(
+ url = "www.mozilla.com",
+ ),
+ )
+ val sourceText = "unknown"
+
+ every { controller.handleNavigateToBrowser() } just runs
+
+ assertNull(TabsTray.openedExistingTab.testGetValue())
+
+ controller.handleTabSelected(tab, null)
+
+ assertNotNull(TabsTray.openedExistingTab.testGetValue())
+ val snapshot = TabsTray.openedExistingTab.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals(sourceText, snapshot.single().extra?.getValue("opened_existing_tab"))
+
+ verify { tabsUseCases.selectTab(tab.id) }
+ verify { controller.handleNavigateToBrowser() }
+ }
+
+ @Test
+ fun `GIVEN a private tab is open and selected with a normal tab also open WHEN the private tab is closed and private home page shown and normal tab is selected from tabs tray THEN normal tab is displayed `() {
+ val normalTab = TabSessionState(
+ content = ContentState(url = "https://simulate.com", private = false),
+ id = "normalTab",
+ )
+ val privateTab = TabSessionState(
+ content = ContentState(url = "https://mozilla.com", private = true),
+ id = "privateTab",
+ )
+ trayStore = TabsTrayStore()
+ browserStore = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(normalTab, privateTab),
+ ),
+ )
+ browsingModeManager = spyk(
+ DefaultBrowsingModeManager(
+ _mode = BrowsingMode.Private,
+ settings = settings,
+ modeDidChange = mockk(relaxed = true),
+ ),
+ )
+ val controller = spyk(createController())
+
+ try {
+ mockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
+ browserStore.dispatch(TabListAction.SelectTabAction(privateTab.id)).joinBlocking()
+ controller.handleTabSelected(privateTab, null)
+
+ assertEquals(privateTab.id, browserStore.state.selectedTabId)
+ assertEquals(true, browsingModeManager.mode.isPrivate)
+ verify { appStore.dispatch(AppAction.ModeChange(BrowsingMode.Private)) }
+
+ controller.handleTabDeletion("privateTab")
+ browserStore.dispatch(TabListAction.SelectTabAction(normalTab.id)).joinBlocking()
+ controller.handleTabSelected(normalTab, null)
+
+ assertEquals(normalTab.id, browserStore.state.selectedTabId)
+ assertEquals(false, browsingModeManager.mode.isPrivate)
+ verify { appStore.dispatch(AppAction.ModeChange(BrowsingMode.Normal)) }
+ } finally {
+ unmockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
+ }
+ }
+
+ @Test
+ fun `GIVEN a normal tab is selected WHEN the last private tab is deleted THEN that private tab is removed and an undo snackbar is shown and original normal tab is still displayed`() {
+ val currentTab = TabSessionState(content = ContentState(url = "https://simulate.com", private = false), id = "currentTab")
+ val privateTab = TabSessionState(content = ContentState(url = "https://mozilla.com", private = true), id = "privateTab")
+ var showUndoSnackbarForTabInvoked = false
+ var navigateToHomeAndDeleteSessionInvoked = false
+ trayStore = TabsTrayStore()
+ browserStore = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(currentTab, privateTab),
+ selectedTabId = currentTab.id,
+ ),
+ )
+ val controller = spyk(
+ createController(
+ showUndoSnackbarForTab = {
+ showUndoSnackbarForTabInvoked = true
+ },
+ navigateToHomeAndDeleteSession = {
+ navigateToHomeAndDeleteSessionInvoked = true
+ },
+ ),
+ )
+
+ controller.handleTabSelected(currentTab, "source")
+ controller.handleTabDeletion("privateTab")
+
+ assertTrue(showUndoSnackbarForTabInvoked)
+ assertFalse(navigateToHomeAndDeleteSessionInvoked)
+ }
+
+ @Test
+ fun `GIVEN no tabs are currently selected WHEN a normal tab is long clicked THEN the tab is selected and the metric is reported`() {
+ val normalTab = TabSessionState(
+ content = ContentState(url = "https://simulate.com", private = false),
+ id = "normalTab",
+ )
+ every { trayStore.state.mode.selectedTabs } returns emptySet()
+
+ assertNull(Collections.longPress.testGetValue())
+
+ createController().handleTabLongClick(normalTab)
+
+ assertNotNull(Collections.longPress.testGetValue())
+ verify { trayStore.dispatch(TabsTrayAction.AddSelectTab(normalTab)) }
+ }
+
+ @Test
+ fun `GIVEN at least one tab is selected WHEN a normal tab is long clicked THEN the long click is ignored`() {
+ val normalTabClicked = TabSessionState(
+ content = ContentState(url = "https://simulate.com", private = false),
+ id = "normalTab",
+ )
+ val alreadySelectedTab = TabSessionState(
+ content = ContentState(url = "https://simulate.com", private = false),
+ id = "selectedTab",
+ )
+ every { trayStore.state.mode.selectedTabs } returns setOf(alreadySelectedTab)
+
+ createController().handleTabLongClick(normalTabClicked)
+
+ assertNull(Collections.longPress.testGetValue())
+ verify(exactly = 0) { trayStore.dispatch(any()) }
+ }
+
+ @Test
+ fun `WHEN a private tab is long clicked THEN the long click is ignored`() {
+ val privateTab = TabSessionState(
+ content = ContentState(url = "https://simulate.com", private = true),
+ id = "privateTab",
+ )
+
+ createController().handleTabLongClick(privateTab)
+
+ assertNull(Collections.longPress.testGetValue())
+ verify(exactly = 0) { trayStore.dispatch(any()) }
+ }
+
+ @Test
+ fun `GIVEN one tab is selected WHEN the share button is clicked THEN report the telemetry and navigate away`() {
+ every { trayStore.state.mode.selectedTabs } returns setOf(createTab(url = "https://mozilla.org"))
+
+ createController().handleShareSelectedTabsClicked()
+
+ verify(exactly = 1) { navController.navigate(any<NavDirections>()) }
+
+ assertNotNull(TabsTray.shareSelectedTabs.testGetValue())
+ val snapshot = TabsTray.shareSelectedTabs.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("1", snapshot.single().extra?.getValue("tab_count"))
+ }
+
+ @Test
+ fun `GIVEN one tab is selected WHEN the add selected tabs to collection button is clicked THEN report the telemetry and show the collections dialog`() {
+ mockkStatic("org.mozilla.fenix.collections.CollectionsDialogKt")
+
+ val controller = spyk(createController())
+ every { controller.showCollectionsDialog(any()) } just runs
+
+ every { trayStore.state.mode.selectedTabs } returns setOf(createTab(url = "https://mozilla.org"))
+ every { any<CollectionsDialog>().show(any()) } answers { }
+ assertNull(TabsTray.saveToCollection.testGetValue())
+
+ controller.handleAddSelectedTabsToCollectionClicked()
+
+ assertNotNull(TabsTray.saveToCollection.testGetValue())
+
+ unmockkStatic("org.mozilla.fenix.collections.CollectionsDialogKt")
+ }
+
+ @Test
+ fun `GIVEN one tab is selected WHEN the save selected tabs to bookmarks button is clicked THEN report the telemetry and show a snackbar`() = runTestOnMain {
+ var showBookmarkSnackbarInvoked = false
+
+ every { trayStore.state.mode.selectedTabs } returns setOf(createTab(url = "https://mozilla.org"))
+
+ createController(
+ showBookmarkSnackbar = {
+ showBookmarkSnackbarInvoked = true
+ },
+ ).handleBookmarkSelectedTabsClicked()
+
+ coVerify(exactly = 1) { bookmarksUseCase.addBookmark(any(), any(), any(), any()) }
+ assertTrue(showBookmarkSnackbarInvoked)
+
+ assertNotNull(TabsTray.bookmarkSelectedTabs.testGetValue())
+ val snapshot = TabsTray.bookmarkSelectedTabs.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("1", snapshot.single().extra?.getValue("tab_count"))
+ }
+
+ @Test
+ fun `WHEN the normal tabs page button is clicked THEN report the metric`() {
+ assertNull(TabsTray.normalModeTapped.testGetValue())
+
+ createController().handleTrayScrollingToPosition(Page.NormalTabs.ordinal, false)
+
+ assertNotNull(TabsTray.normalModeTapped.testGetValue())
+ }
+
+ @Test
+ fun `WHEN the private tabs page button is clicked THEN report the metric`() {
+ assertNull(TabsTray.privateModeTapped.testGetValue())
+
+ createController().handleTrayScrollingToPosition(Page.PrivateTabs.ordinal, false)
+
+ assertNotNull(TabsTray.privateModeTapped.testGetValue())
+ }
+
+ @Test
+ fun `WHEN the synced tabs page button is clicked THEN report the metric`() {
+ assertNull(TabsTray.syncedModeTapped.testGetValue())
+
+ createController().handleTrayScrollingToPosition(Page.SyncedTabs.ordinal, false)
+
+ assertNotNull(TabsTray.syncedModeTapped.testGetValue())
+ }
+
+ private fun createController(
+ navigateToHomeAndDeleteSession: (String) -> Unit = { },
+ selectTabPosition: (Int, Boolean) -> Unit = { _, _ -> },
+ dismissTray: () -> Unit = { },
+ showUndoSnackbarForTab: (Boolean) -> Unit = { _ -> },
+ showCancelledDownloadWarning: (Int, String?, String?) -> Unit = { _, _, _ -> },
+ showCollectionSnackbar: (Int, Boolean) -> Unit = { _, _ -> },
+ showBookmarkSnackbar: (Int) -> Unit = { _ -> },
+ ): DefaultTabsTrayController {
+ return DefaultTabsTrayController(
+ activity = activity,
+ appStore = appStore,
+ tabsTrayStore = trayStore,
+ browserStore = browserStore,
+ settings = settings,
+ browsingModeManager = browsingModeManager,
+ navController = navController,
+ navigateToHomeAndDeleteSession = navigateToHomeAndDeleteSession,
+ profiler = profiler,
+ navigationInteractor = navigationInteractor,
+ tabsUseCases = tabsUseCases,
+ bookmarksUseCase = bookmarksUseCase,
+ collectionStorage = collectionStorage,
+ ioDispatcher = testDispatcher,
+ selectTabPosition = selectTabPosition,
+ dismissTray = dismissTray,
+ showUndoSnackbarForTab = showUndoSnackbarForTab,
+ showCancelledDownloadWarning = showCancelledDownloadWarning,
+ showCollectionSnackbar = showCollectionSnackbar,
+ showBookmarkSnackbar = showBookmarkSnackbar,
+ bookmarksSharedViewModel = bookmarksSharedViewModel,
+ )
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/DefaultTabsTrayInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/DefaultTabsTrayInteractorTest.kt
new file mode 100644
index 0000000000..81d8605276
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/DefaultTabsTrayInteractorTest.kt
@@ -0,0 +1,163 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import io.mockk.verifySequence
+import mozilla.components.browser.state.state.ContentState
+import mozilla.components.browser.state.state.TabSessionState
+import org.junit.Test
+
+class DefaultTabsTrayInteractorTest {
+
+ private val controller: TabsTrayController = mockk(relaxed = true)
+ private val interactor = DefaultTabsTrayInteractor(controller)
+
+ @Test
+ fun `WHEN user selects a new tray page THEN the Interactor delegates to the controller`() {
+ interactor.onTrayPositionSelected(14, true)
+
+ verifySequence { controller.handleTrayScrollingToPosition(14, true) }
+ }
+
+ @Test
+ fun `WHEN user selects a new browser tab THEN the Interactor delegates to the controller`() {
+ val tab: TabSessionState = mockk()
+ interactor.onTabSelected(tab, null)
+
+ verifySequence { controller.handleTabSelected(tab, null) }
+ }
+
+ @Test
+ fun `WHEN user deletes one browser tab page THEN the Interactor delegates to the controller`() {
+ val tab: TabSessionState = mockk()
+ val id = "testTabId"
+ every { tab.id } returns id
+ interactor.onTabClosed(tab)
+
+ verifySequence { controller.handleTabDeletion(id) }
+ }
+
+ @Test
+ fun `WHEN user confirms downloads cancellation THEN the Interactor delegates to the controller`() {
+ interactor.onDeletePrivateTabWarningAccepted("testTabId")
+
+ verifySequence { controller.handleDeleteTabWarningAccepted("testTabId") }
+ }
+
+ @Test
+ fun `WHEN user clicks to delete the selected tabs THEN the Interactor delegates to the controller`() {
+ interactor.onDeleteSelectedTabsClicked()
+
+ verify { controller.handleDeleteSelectedTabsClicked() }
+ }
+
+ @Test
+ fun `WHEN user clicks to force the selected tabs as inactive THEN the Interactor delegates to the controller`() {
+ interactor.onForceSelectedTabsAsInactiveClicked()
+
+ verify { controller.handleForceSelectedTabsAsInactiveClicked() }
+ }
+
+ @Test
+ fun `WHEN user clicks to bookmark the selected tabs THEN the Interactor delegates to the controller`() {
+ interactor.onBookmarkSelectedTabsClicked()
+
+ verify { controller.handleBookmarkSelectedTabsClicked() }
+ }
+
+ @Test
+ fun `WHEN user clicks to save the selected tabs to a collection THEN the Interactor delegates to the controller`() {
+ interactor.onAddSelectedTabsToCollectionClicked()
+
+ verify { controller.handleAddSelectedTabsToCollectionClicked() }
+ }
+
+ @Test
+ fun `WHEN user clicks to share the selected tabs THEN the Interactor delegates to the controller`() {
+ interactor.onShareSelectedTabs()
+
+ verify { controller.handleShareSelectedTabsClicked() }
+ }
+
+ @Test
+ fun `WHEN the inactive tabs header is clicked THEN update the expansion state of the inactive tabs card`() {
+ interactor.onInactiveTabsHeaderClicked(true)
+
+ verify { controller.handleInactiveTabsHeaderClicked(true) }
+ }
+
+ @Test
+ fun `WHEN the inactive tabs auto close dialog's close button is clicked THEN dismiss the dialog`() {
+ interactor.onAutoCloseDialogCloseButtonClicked()
+
+ verify { controller.handleInactiveTabsAutoCloseDialogDismiss() }
+ }
+
+ @Test
+ fun `WHEN the enable inactive tabs auto close button is clicked THEN turn on the auto close feature`() {
+ interactor.onEnableAutoCloseClicked()
+
+ verify { controller.handleEnableInactiveTabsAutoCloseClicked() }
+ }
+
+ @Test
+ fun `WHEN an inactive tab is clicked THEN open the tab`() {
+ val tab = TabSessionState(
+ id = "tabId",
+ content = ContentState(
+ url = "www.mozilla.com",
+ ),
+ )
+
+ interactor.onInactiveTabClicked(tab)
+
+ verify { controller.handleInactiveTabClicked(tab) }
+ }
+
+ @Test
+ fun `WHEN an inactive tab is clicked to be closed THEN close the tab`() {
+ val tab = TabSessionState(
+ id = "tabId",
+ content = ContentState(
+ url = "www.mozilla.com",
+ ),
+ )
+
+ interactor.onInactiveTabClosed(tab)
+
+ verify { controller.handleCloseInactiveTabClicked(tab) }
+ }
+
+ @Test
+ fun `WHEN the close all inactive tabs button is clicked THEN delete all inactive tabs`() {
+ interactor.onDeleteAllInactiveTabsClicked()
+
+ verify { controller.handleDeleteAllInactiveTabsClicked() }
+ }
+
+ @Test
+ fun `GIVEN the user is viewing normal tabs WHEN the user clicks on the FAB THEN the Interactor delegates to the controller`() {
+ interactor.onNormalTabsFabClicked()
+
+ verifySequence { controller.handleNormalTabsFabClick() }
+ }
+
+ @Test
+ fun `GIVEN the user is viewing private tabs WHEN the user clicks on the FAB THEN the Interactor delegates to the controller`() {
+ interactor.onPrivateTabsFabClicked()
+
+ verifySequence { controller.handlePrivateTabsFabClick() }
+ }
+
+ @Test
+ fun `GIVEN the user is viewing synced tabs WHEN the user clicks on the FAB THEN the Interactor delegates to the controller`() {
+ interactor.onSyncedTabsFabClicked()
+
+ verifySequence { controller.handleSyncedTabsFabClick() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/FloatingActionButtonBindingTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/FloatingActionButtonBindingTest.kt
new file mode 100644
index 0000000000..cbdfc01b98
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/FloatingActionButtonBindingTest.kt
@@ -0,0 +1,168 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray
+
+import androidx.appcompat.content.res.AppCompatResources
+import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.R
+import org.mozilla.fenix.tabstray.browser.TabsTrayFabInteractor
+
+class FloatingActionButtonBindingTest {
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ private val actionButton: ExtendedFloatingActionButton = mockk(relaxed = true)
+ private val interactor: TabsTrayFabInteractor = mockk(relaxed = true)
+
+ @Before
+ fun setup() {
+ mockkStatic(AppCompatResources::class)
+ every { AppCompatResources.getDrawable(any(), any()) } returns mockk(relaxed = true)
+ }
+
+ @After
+ fun teardown() {
+ unmockkStatic(AppCompatResources::class)
+ }
+
+ @Test
+ fun `WHEN tab selected page is normal tab THEN shrink and show is called`() {
+ val tabsTrayStore = TabsTrayStore(TabsTrayState(selectedPage = Page.NormalTabs))
+ val fabBinding = FloatingActionButtonBinding(
+ tabsTrayStore,
+ actionButton,
+ interactor,
+ true,
+ )
+
+ fabBinding.start()
+
+ verify(exactly = 1) { actionButton.shrink() }
+ verify(exactly = 1) { actionButton.show() }
+ verify(exactly = 0) { actionButton.extend() }
+ verify(exactly = 0) { actionButton.hide() }
+ }
+
+ @Test
+ fun `WHEN tab selected page is private tab THEN extend and show is called`() {
+ val tabsTrayStore = TabsTrayStore(TabsTrayState(selectedPage = Page.PrivateTabs))
+ val fabBinding = FloatingActionButtonBinding(
+ tabsTrayStore,
+ actionButton,
+ interactor,
+ true,
+ )
+
+ fabBinding.start()
+
+ verify(exactly = 1) { actionButton.extend() }
+ verify(exactly = 1) { actionButton.show() }
+ verify(exactly = 0) { actionButton.shrink() }
+ verify(exactly = 0) { actionButton.hide() }
+ }
+
+ @Test
+ fun `WHEN tab selected page is sync tab THEN extend and show is called`() {
+ val tabsTrayStore = TabsTrayStore(TabsTrayState(selectedPage = Page.SyncedTabs))
+ val fabBinding = FloatingActionButtonBinding(
+ tabsTrayStore,
+ actionButton,
+ interactor,
+ true,
+ )
+
+ fabBinding.start()
+
+ verify(exactly = 1) { actionButton.extend() }
+ verify(exactly = 1) { actionButton.show() }
+ verify(exactly = 0) { actionButton.shrink() }
+ verify(exactly = 0) { actionButton.hide() }
+ }
+
+ @Test
+ fun `GIVEN the selected tab page is sync WHEN the user is not signed in THEN extend and show is called`() {
+ val tabsTrayStore = TabsTrayStore(TabsTrayState(selectedPage = Page.SyncedTabs))
+ val fabBinding = FloatingActionButtonBinding(
+ tabsTrayStore,
+ actionButton,
+ interactor,
+ false,
+ )
+
+ fabBinding.start()
+
+ verify(exactly = 0) { actionButton.extend() }
+ verify(exactly = 0) { actionButton.show() }
+ verify(exactly = 0) { actionButton.shrink() }
+ verify(exactly = 1) { actionButton.hide() }
+ }
+
+ @Test
+ fun `WHEN selected page is updated THEN button is updated`() {
+ val tabsTrayStore = TabsTrayStore(TabsTrayState(selectedPage = Page.NormalTabs))
+ val fabBinding = FloatingActionButtonBinding(
+ tabsTrayStore,
+ actionButton,
+ interactor,
+ true,
+ )
+
+ fabBinding.start()
+
+ verify(exactly = 1) { actionButton.shrink() }
+ verify(exactly = 1) { actionButton.show() }
+ verify(exactly = 0) { actionButton.extend() }
+ verify(exactly = 0) { actionButton.hide() }
+ verify(exactly = 1) { actionButton.setIconResource(R.drawable.ic_new) }
+ verify(exactly = 1) { actionButton.contentDescription = any() }
+
+ tabsTrayStore.dispatch(TabsTrayAction.PageSelected(Page.positionToPage(Page.PrivateTabs.ordinal)))
+ tabsTrayStore.waitUntilIdle()
+
+ verify(exactly = 1) { actionButton.shrink() }
+ verify(exactly = 2) { actionButton.show() }
+ verify(exactly = 1) { actionButton.extend() }
+ verify(exactly = 0) { actionButton.hide() }
+ verify(exactly = 1) { actionButton.setText(R.string.tab_drawer_fab_content) }
+ verify(exactly = 2) { actionButton.setIconResource(R.drawable.ic_new) }
+ verify(exactly = 2) { actionButton.contentDescription = any() }
+
+ tabsTrayStore.dispatch(TabsTrayAction.PageSelected(Page.positionToPage(Page.SyncedTabs.ordinal)))
+ tabsTrayStore.waitUntilIdle()
+
+ verify(exactly = 1) { actionButton.shrink() }
+ verify(exactly = 3) { actionButton.show() }
+ verify(exactly = 2) { actionButton.extend() }
+ verify(exactly = 0) { actionButton.hide() }
+ verify(exactly = 1) { actionButton.setText(R.string.tab_drawer_fab_sync) }
+ verify(exactly = 1) { actionButton.setIconResource(R.drawable.ic_fab_sync) }
+ verify(exactly = 3) { actionButton.contentDescription = any() }
+
+ tabsTrayStore.dispatch(TabsTrayAction.SyncNow)
+ tabsTrayStore.waitUntilIdle()
+
+ verify(exactly = 1) { actionButton.shrink() }
+ verify(exactly = 4) { actionButton.show() }
+ verify(exactly = 3) { actionButton.extend() }
+ verify(exactly = 0) { actionButton.hide() }
+ verify(exactly = 1) { actionButton.setText(R.string.sync_syncing_in_progress) }
+ verify(exactly = 2) { actionButton.setIconResource(R.drawable.ic_fab_sync) }
+ verify(exactly = 4) { actionButton.contentDescription = any() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/MenuIntegrationTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/MenuIntegrationTest.kt
new file mode 100644
index 0000000000..3dd869bbf8
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/MenuIntegrationTest.kt
@@ -0,0 +1,82 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray
+
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.middleware.CaptureActionsMiddleware
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Assert.assertNotNull
+import org.junit.Rule
+import org.junit.Test
+
+class MenuIntegrationTest {
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ private val captureMiddleware = CaptureActionsMiddleware<TabsTrayState, TabsTrayAction>()
+ private val tabsTrayStore = TabsTrayStore(middlewares = listOf(captureMiddleware))
+ private val interactor = mockk<NavigationInteractor>(relaxed = true)
+
+ @Test
+ fun `WHEN the share all menu item is clicked THEN invoke the action`() {
+ val menu = MenuIntegration(mockk(), mockk(), tabsTrayStore, mockk(), interactor)
+
+ menu.handleMenuClicked(TabsTrayMenu.Item.ShareAllTabs)
+
+ verify { interactor.onShareTabsOfTypeClicked(false) }
+ }
+
+ @Test
+ fun `WHEN the open account settings menu item is clicked THEN invoke the action`() {
+ val menu = MenuIntegration(mockk(), mockk(), tabsTrayStore, mockk(), interactor)
+
+ menu.handleMenuClicked(TabsTrayMenu.Item.OpenAccountSettings)
+
+ verify { interactor.onAccountSettingsClicked() }
+ }
+
+ @Test
+ fun `WHEN the open settings menu item is clicked THEN invoke the action`() {
+ val menu = MenuIntegration(mockk(), mockk(), tabsTrayStore, mockk(), interactor)
+
+ menu.handleMenuClicked(TabsTrayMenu.Item.OpenTabSettings)
+
+ verify { interactor.onTabSettingsClicked() }
+ }
+
+ @Test
+ fun `WHEN the close all menu item is clicked THEN invoke the action`() {
+ val menu = MenuIntegration(mockk(), mockk(), tabsTrayStore, mockk(), interactor)
+
+ menu.handleMenuClicked(TabsTrayMenu.Item.CloseAllTabs)
+
+ verify { interactor.onCloseAllTabsClicked(false) }
+ }
+
+ @Test
+ fun `WHEN the recently menu item is clicked THEN invoke the action`() {
+ val menu = MenuIntegration(mockk(), mockk(), tabsTrayStore, mockk(), interactor)
+
+ menu.handleMenuClicked(TabsTrayMenu.Item.OpenRecentlyClosed)
+
+ verify { interactor.onOpenRecentlyClosedClicked() }
+ }
+
+ @Test
+ fun `WHEN the select menu item is clicked THEN invoke the action`() {
+ val menu = MenuIntegration(mockk(), mockk(), tabsTrayStore, mockk(), interactor)
+
+ menu.handleMenuClicked(TabsTrayMenu.Item.SelectTabs)
+
+ tabsTrayStore.waitUntilIdle()
+
+ assertNotNull(captureMiddleware.findLastAction(TabsTrayAction.EnterSelectMode::class))
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/NavigationInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/NavigationInteractorTest.kt
new file mode 100644
index 0000000000..b224566f18
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/NavigationInteractorTest.kt
@@ -0,0 +1,181 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray
+
+import androidx.navigation.NavController
+import androidx.navigation.NavDirections
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.spyk
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import mozilla.components.browser.state.selector.findTab
+import mozilla.components.browser.state.selector.getNormalOrPrivateTabs
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.content.DownloadState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.service.fxa.manager.FxaAccountManager
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.RuleChain
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.Events
+import org.mozilla.fenix.GleanMetrics.TabsTray
+import org.mozilla.fenix.components.accounts.FenixFxAEntryPoint
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import mozilla.components.browser.state.state.createTab as createStateTab
+
+@RunWith(FenixRobolectricTestRunner::class) // for gleanTestRule
+class NavigationInteractorTest {
+ private lateinit var store: BrowserStore
+ private val testTab: TabSessionState = createStateTab(url = "https://mozilla.org")
+ private val navController: NavController = mockk(relaxed = true)
+ private val accountManager: FxaAccountManager = mockk(relaxed = true)
+
+ val coroutinesTestRule: MainCoroutineRule = MainCoroutineRule()
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @get:Rule
+ val chain: RuleChain = RuleChain.outerRule(gleanTestRule).around(coroutinesTestRule)
+
+ @Before
+ fun setup() {
+ store = BrowserStore(initialState = BrowserState(tabs = listOf(testTab)))
+ }
+
+ @Test
+ fun `onTabTrayDismissed calls dismissTabTray on DefaultNavigationInteractor`() {
+ var dismissTabTrayInvoked = false
+
+ assertNull(TabsTray.closed.testGetValue())
+
+ createInteractor(
+ dismissTabTray = {
+ dismissTabTrayInvoked = true
+ },
+ ).onTabTrayDismissed()
+
+ assertTrue(dismissTabTrayInvoked)
+ assertNotNull(TabsTray.closed.testGetValue())
+ }
+
+ @Test
+ fun `onAccountSettingsClicked calls navigation on DefaultNavigationInteractor`() {
+ every { accountManager.authenticatedAccount() }.answers { mockk(relaxed = true) }
+
+ createInteractor().onAccountSettingsClicked()
+
+ verify(exactly = 1) { navController.navigate(TabsTrayFragmentDirections.actionGlobalAccountSettingsFragment()) }
+ }
+
+ @Test
+ fun `onAccountSettingsClicked when not logged in calls navigation to turn on sync`() {
+ every { accountManager.authenticatedAccount() }.answers { null }
+
+ createInteractor().onAccountSettingsClicked()
+
+ verify(exactly = 1) {
+ navController.navigate(
+ TabsTrayFragmentDirections.actionGlobalTurnOnSync(
+ entrypoint = FenixFxAEntryPoint.NavigationInteraction,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `onTabSettingsClicked calls navigation on DefaultNavigationInteractor`() {
+ createInteractor().onTabSettingsClicked()
+ verify(exactly = 1) { navController.navigate(TabsTrayFragmentDirections.actionGlobalTabSettingsFragment()) }
+ }
+
+ @Test
+ fun `onOpenRecentlyClosedClicked calls navigation on DefaultNavigationInteractor`() {
+ assertNull(Events.recentlyClosedTabsOpened.testGetValue())
+
+ createInteractor().onOpenRecentlyClosedClicked()
+
+ verify(exactly = 1) { navController.navigate(TabsTrayFragmentDirections.actionGlobalRecentlyClosed()) }
+ assertNotNull(Events.recentlyClosedTabsOpened.testGetValue())
+ }
+
+ @Test
+ fun `onCloseAllTabsClicked calls navigation on DefaultNavigationInteractor`() {
+ var dismissTabTrayAndNavigateHomeInvoked = false
+ createInteractor(
+ dismissTabTrayAndNavigateHome = {
+ dismissTabTrayAndNavigateHomeInvoked = true
+ },
+ ).onCloseAllTabsClicked(false)
+
+ assertTrue(dismissTabTrayAndNavigateHomeInvoked)
+ }
+
+ @Test
+ fun `GIVEN active private download WHEN onCloseAllTabsClicked is called for private tabs THEN showCancelledDownloadWarning is called`() {
+ var showCancelledDownloadWarningInvoked = false
+ val mockedStore: BrowserStore = mockk()
+ val controller = spyk(
+ createInteractor(
+ browserStore = mockedStore,
+ showCancelledDownloadWarning = { _, _, _ ->
+ showCancelledDownloadWarningInvoked = true
+ },
+ ),
+ )
+ val tab: TabSessionState = mockk { every { content.private } returns true }
+ every { mockedStore.state } returns mockk()
+ every { mockedStore.state.downloads } returns mapOf(
+ "1" to DownloadState(
+ "https://mozilla.org/download",
+ private = true,
+ destinationDirectory = "Download",
+ status = DownloadState.Status.DOWNLOADING,
+ ),
+ )
+ try {
+ mockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
+ every { mockedStore.state.findTab(any()) } returns tab
+ every { mockedStore.state.getNormalOrPrivateTabs(any()) } returns listOf(tab)
+
+ controller.onCloseAllTabsClicked(true)
+
+ assertTrue(showCancelledDownloadWarningInvoked)
+ } finally {
+ unmockkStatic("mozilla.components.browser.state.selector.SelectorsKt")
+ }
+ }
+
+ @Test
+ fun `onShareTabsOfType calls navigation on DefaultNavigationInteractor`() {
+ createInteractor().onShareTabsOfTypeClicked(false)
+ verify(exactly = 1) { navController.navigate(any<NavDirections>()) }
+ }
+
+ private fun createInteractor(
+ browserStore: BrowserStore = store,
+ dismissTabTray: () -> Unit = { },
+ dismissTabTrayAndNavigateHome: (String) -> Unit = { _ -> },
+ showCancelledDownloadWarning: (Int, String?, String?) -> Unit = { _, _, _ -> },
+ ): NavigationInteractor {
+ return DefaultNavigationInteractor(
+ browserStore,
+ navController,
+ dismissTabTray,
+ dismissTabTrayAndNavigateHome,
+ showCancelledDownloadWarning,
+ accountManager,
+ )
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/SecureTabsTrayBindingTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/SecureTabsTrayBindingTest.kt
new file mode 100644
index 0000000000..b30bf345a4
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/SecureTabsTrayBindingTest.kt
@@ -0,0 +1,123 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray
+
+import android.view.Window
+import android.view.WindowManager
+import androidx.appcompat.content.res.AppCompatResources
+import androidx.fragment.app.Fragment
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.ext.removeSecure
+import org.mozilla.fenix.ext.secure
+import org.mozilla.fenix.utils.Settings
+
+class SecureTabsTrayBindingTest {
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ private val settings: Settings = mockk(relaxed = true)
+ private val fragment: Fragment = mockk(relaxed = true)
+ private val dialog: TabsTrayDialog = mockk(relaxed = true)
+ private val window: Window = mockk(relaxed = true)
+
+ @Before
+ fun setup() {
+ mockkStatic(AppCompatResources::class)
+ every { AppCompatResources.getDrawable(any(), any()) } returns mockk(relaxed = true)
+ every { fragment.secure() } just Runs
+ every { fragment.removeSecure() } just Runs
+ every { dialog.window } returns window
+ every { window.addFlags(any()) } just Runs
+ every { window.clearFlags(any()) } just Runs
+ }
+
+ @Test
+ fun `WHEN tab selected page switches to private THEN set fragment to secure`() {
+ val tabsTrayStore = TabsTrayStore(TabsTrayState())
+ val secureTabsTrayBinding = SecureTabsTrayBinding(
+ store = tabsTrayStore,
+ settings = settings,
+ fragment = fragment,
+ dialog = dialog,
+ )
+
+ secureTabsTrayBinding.start()
+ tabsTrayStore.dispatch(TabsTrayAction.PageSelected(Page.positionToPage(Page.PrivateTabs.ordinal)))
+ tabsTrayStore.waitUntilIdle()
+
+ verify { fragment.secure() }
+ verify { window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) }
+ }
+
+ @Test
+ fun `WHEN tab selected page switches to private and allowScreenshotsInPrivateMode true THEN set fragment to un-secure`() {
+ val tabsTrayStore = TabsTrayStore(TabsTrayState())
+ val secureTabsTrayBinding = SecureTabsTrayBinding(
+ store = tabsTrayStore,
+ settings = settings,
+ fragment = fragment,
+ dialog = dialog,
+ )
+ every { settings.allowScreenshotsInPrivateMode } returns true
+
+ secureTabsTrayBinding.start()
+ tabsTrayStore.dispatch(TabsTrayAction.PageSelected(Page.positionToPage(Page.PrivateTabs.ordinal)))
+ tabsTrayStore.waitUntilIdle()
+
+ verify { fragment.removeSecure() }
+ verify { window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }
+ }
+
+ @Test
+ fun `GIVEN not in private mode WHEN tab selected page switches to normal tabs from private THEN set fragment to un-secure`() {
+ every { settings.lastKnownMode.isPrivate } returns false
+ val tabsTrayStore = TabsTrayStore(TabsTrayState())
+ val secureTabsTrayBinding = SecureTabsTrayBinding(
+ store = tabsTrayStore,
+ settings = settings,
+ fragment = fragment,
+ dialog = dialog,
+ )
+
+ secureTabsTrayBinding.start()
+ tabsTrayStore.dispatch(TabsTrayAction.PageSelected(Page.positionToPage(Page.NormalTabs.ordinal)))
+ tabsTrayStore.waitUntilIdle()
+
+ verify { fragment.removeSecure() }
+ verify { window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }
+ }
+
+ @Test
+ fun `GIVEN private mode WHEN tab selected page switches to normal tabs from private THEN do nothing`() {
+ every { settings.lastKnownMode.isPrivate } returns true
+ val tabsTrayStore = TabsTrayStore(TabsTrayState())
+ val secureTabsTrayBinding = SecureTabsTrayBinding(
+ store = tabsTrayStore,
+ settings = settings,
+ fragment = fragment,
+ dialog = dialog,
+ )
+
+ secureTabsTrayBinding.start()
+ tabsTrayStore.dispatch(TabsTrayAction.PageSelected(Page.positionToPage(Page.NormalTabs.ordinal)))
+ tabsTrayStore.waitUntilIdle()
+
+ verify(exactly = 0) { fragment.removeSecure() }
+ verify(exactly = 0) { window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabCounterBindingTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabCounterBindingTest.kt
new file mode 100644
index 0000000000..eb594cb546
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabCounterBindingTest.kt
@@ -0,0 +1,52 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray
+
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.ui.tabcounter.TabCounter
+import org.junit.Rule
+import org.junit.Test
+
+class TabCounterBindingTest {
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Test
+ fun `WHEN normalTabs changes THEN update counter`() {
+ val store = BrowserStore()
+ val counter = mockk<TabCounter>(relaxed = true)
+ val binding = TabCounterBinding(store, counter)
+
+ binding.start()
+
+ store.dispatch(TabListAction.AddTabAction(createTab("https://mozilla.org")))
+
+ store.waitUntilIdle()
+
+ verify { counter.setCount(1) }
+ }
+
+ @Test
+ fun `WHEN feature starts THEN update counter`() {
+ val store = BrowserStore()
+ val counter = mockk<TabCounter>(relaxed = true)
+ val binding = TabCounterBinding(store, counter)
+
+ binding.start()
+
+ store.waitUntilIdle()
+
+ verify { counter.setCount(0) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabLayoutMediatorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabLayoutMediatorTest.kt
new file mode 100644
index 0000000000..69bc0870d6
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabLayoutMediatorTest.kt
@@ -0,0 +1,107 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray
+
+import androidx.viewpager2.widget.ViewPager2
+import com.google.android.material.tabs.TabLayout
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Test
+import org.mozilla.fenix.browser.browsingmode.BrowsingMode
+import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
+import org.mozilla.fenix.tabstray.TrayPagerAdapter.Companion.POSITION_NORMAL_TABS
+import org.mozilla.fenix.tabstray.TrayPagerAdapter.Companion.POSITION_PRIVATE_TABS
+import org.mozilla.fenix.tabstray.TrayPagerAdapter.Companion.POSITION_SYNCED_TABS
+
+class TabLayoutMediatorTest {
+ private val modeManager: BrowsingModeManager = mockk(relaxed = true)
+ private val tabsTrayStore: TabsTrayStore = mockk(relaxed = true)
+ private val interactor: TabsTrayInteractor = mockk(relaxed = true)
+ private val tabLayout: TabLayout = mockk(relaxed = true)
+ private val tab: TabLayout.Tab = mockk(relaxed = true)
+ private val viewPager: ViewPager2 = mockk(relaxed = true)
+
+ @Test
+ fun `page to normal tab position when mode is also normal`() {
+ val mediator = TabLayoutMediator(tabLayout, viewPager, interactor, modeManager, tabsTrayStore)
+
+ val mockState: TabsTrayState = mockk()
+ every { modeManager.mode }.answers { BrowsingMode.Normal }
+ every { tabLayout.getTabAt(POSITION_NORMAL_TABS) }.answers { tab }
+ every { tabsTrayStore.state } returns mockState
+ every { mockState.selectedPage } returns Page.NormalTabs
+
+ mediator.selectActivePage()
+
+ verify { tab.select() }
+ verify { viewPager.setCurrentItem(POSITION_NORMAL_TABS, false) }
+ verify { tabsTrayStore.dispatch(TabsTrayAction.PageSelected(Page.positionToPage(POSITION_NORMAL_TABS))) }
+ }
+
+ @Test
+ fun `page to private tab position when mode is also private`() {
+ val mediator = TabLayoutMediator(tabLayout, viewPager, interactor, modeManager, tabsTrayStore)
+
+ every { modeManager.mode }.answers { BrowsingMode.Private }
+ every { tabLayout.getTabAt(POSITION_PRIVATE_TABS) }.answers { tab }
+
+ mediator.selectActivePage()
+
+ verify { tab.select() }
+ verify { tabsTrayStore.dispatch(TabsTrayAction.PageSelected(Page.positionToPage(POSITION_PRIVATE_TABS))) }
+ }
+
+ @Test
+ fun `page to synced tabs when selected page is also synced tabs`() {
+ val mediator = TabLayoutMediator(tabLayout, viewPager, interactor, modeManager, tabsTrayStore)
+
+ val mockState: TabsTrayState = mockk()
+ every { modeManager.mode }.answers { BrowsingMode.Normal }
+ every { tabsTrayStore.state } returns mockState
+ every { mockState.selectedPage } returns Page.SyncedTabs
+
+ mediator.selectActivePage()
+
+ verify { viewPager.setCurrentItem(POSITION_SYNCED_TABS, false) }
+ }
+
+ @Test
+ fun `selectTabAtPosition will dispatch the correct TabsTrayStore action`() {
+ val mediator = TabLayoutMediator(tabLayout, viewPager, interactor, modeManager, tabsTrayStore)
+
+ every { tabLayout.getTabAt(POSITION_NORMAL_TABS) }.answers { tab }
+ every { tabLayout.getTabAt(POSITION_PRIVATE_TABS) }.answers { tab }
+ every { tabLayout.getTabAt(POSITION_SYNCED_TABS) }.answers { tab }
+
+ mediator.selectTabAtPosition(POSITION_NORMAL_TABS)
+ verify { tab.select() }
+ verify { tabsTrayStore.dispatch(TabsTrayAction.PageSelected(Page.positionToPage(POSITION_NORMAL_TABS))) }
+
+ mediator.selectTabAtPosition(POSITION_PRIVATE_TABS)
+ verify { tab.select() }
+ verify { tabsTrayStore.dispatch(TabsTrayAction.PageSelected(Page.positionToPage(POSITION_PRIVATE_TABS))) }
+
+ mediator.selectTabAtPosition(POSITION_SYNCED_TABS)
+ verify { tab.select() }
+ verify { tabsTrayStore.dispatch(TabsTrayAction.PageSelected(Page.positionToPage(POSITION_SYNCED_TABS))) }
+ }
+
+ @Test
+ fun `lifecycle methods adds and removes observer`() {
+ val mediator = TabLayoutMediator(tabLayout, viewPager, interactor, modeManager, tabsTrayStore)
+
+ every { modeManager.mode }.answers { BrowsingMode.Private }
+
+ mediator.start()
+
+ verify { tabLayout.addOnTabSelectedListener(any()) }
+ verify { tabsTrayStore.dispatch(TabsTrayAction.PageSelected(Page.positionToPage(POSITION_PRIVATE_TABS))) }
+
+ mediator.stop()
+
+ verify { tabLayout.removeOnTabSelectedListener(any()) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabLayoutObserverTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabLayoutObserverTest.kt
new file mode 100644
index 0000000000..ff8c84f273
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabLayoutObserverTest.kt
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray
+
+import com.google.android.material.tabs.TabLayout
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.middleware.CaptureActionsMiddleware
+import org.junit.Before
+import org.junit.Test
+
+class TabLayoutObserverTest {
+ private val interactor = mockk<TabsTrayInteractor>(relaxed = true)
+ private lateinit var store: TabsTrayStore
+ private val middleware = CaptureActionsMiddleware<TabsTrayState, TabsTrayAction>()
+
+ @Before
+ fun setup() {
+ store = TabsTrayStore(middlewares = listOf(middleware))
+ }
+
+ @Test
+ fun `WHEN tab is selected THEN notify the interactor`() {
+ val observer = TabLayoutObserver(interactor)
+ val tab = mockk<TabLayout.Tab>()
+ every { tab.position } returns 1
+
+ observer.onTabSelected(tab)
+
+ store.waitUntilIdle()
+
+ verify { interactor.onTrayPositionSelected(1, false) }
+
+ every { tab.position } returns 0
+
+ observer.onTabSelected(tab)
+
+ store.waitUntilIdle()
+
+ verify { interactor.onTrayPositionSelected(0, true) }
+
+ every { tab.position } returns 2
+
+ observer.onTabSelected(tab)
+
+ store.waitUntilIdle()
+
+ verify { interactor.onTrayPositionSelected(2, true) }
+ }
+
+ @Test
+ fun `WHEN observer is first started THEN do not smooth scroll`() {
+ val observer = TabLayoutObserver(interactor)
+ val tab = mockk<TabLayout.Tab>()
+ every { tab.position } returns 1
+
+ observer.onTabSelected(tab)
+
+ verify { interactor.onTrayPositionSelected(1, false) }
+
+ observer.onTabSelected(tab)
+
+ verify { interactor.onTrayPositionSelected(1, true) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabSheetBehaviorManagerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabSheetBehaviorManagerTest.kt
new file mode 100644
index 0000000000..73aeae7038
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabSheetBehaviorManagerTest.kt
@@ -0,0 +1,897 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray
+
+import android.content.res.Configuration
+import android.util.DisplayMetrics
+import android.view.View
+import androidx.constraintlayout.widget.ConstraintLayout
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED
+import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_DRAGGING
+import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED
+import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EXPANDED
+import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN
+import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_SETTLING
+import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
+import io.mockk.Called
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.spyk
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import mozilla.components.support.ktx.android.util.dpToPx
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class TabSheetBehaviorManagerTest {
+
+ @Test
+ fun `WHEN state is hidden THEN invoke interactor`() {
+ val interactor = mockk<NavigationInteractor>(relaxed = true)
+ val callback = TraySheetBehaviorCallback(mockk(), interactor, mockk(), mockk())
+
+ callback.onStateChanged(mockk(), STATE_HIDDEN)
+
+ verify { interactor.onTabTrayDismissed() }
+ }
+
+ @Test
+ fun `WHEN state is half-expanded THEN close the tray`() {
+ val behavior = mockk<BottomSheetBehavior<ConstraintLayout>>(relaxed = true)
+ val callback = TraySheetBehaviorCallback(behavior, mockk(), mockk(), mockk())
+
+ callback.onStateChanged(mockk(), STATE_HALF_EXPANDED)
+
+ verify { behavior.state = STATE_HIDDEN }
+ }
+
+ @Test
+ fun `WHEN other states are invoked THEN do nothing`() {
+ val behavior = mockk<BottomSheetBehavior<ConstraintLayout>>(relaxed = true)
+ val interactor = mockk<NavigationInteractor>(relaxed = true)
+ val callback = TraySheetBehaviorCallback(behavior, interactor, mockk(), mockk())
+
+ callback.onStateChanged(mockk(), STATE_COLLAPSED)
+ callback.onStateChanged(mockk(), STATE_DRAGGING)
+ callback.onStateChanged(mockk(), STATE_SETTLING)
+ callback.onStateChanged(mockk(), STATE_EXPANDED)
+
+ verify { behavior wasNot Called }
+ verify { interactor wasNot Called }
+ }
+
+ @Test
+ fun `GIVEN converted dim value is more than max dim WHEN onSlide is called THEN it does not set the dialog dim amount`() {
+ val behavior = mockk<BottomSheetBehavior<ConstraintLayout>>(relaxed = true)
+
+ val tabsTrayDialog = mockk<TabsTrayDialog>(relaxed = true)
+
+ val rootView = mockk<View>(relaxed = true)
+ every { rootView.bottom } returns 1000
+ val fab = mockk<ExtendedFloatingActionButton>(relaxed = true)
+ every { fab.rootView } returns rootView
+
+ val callback = TraySheetBehaviorCallback(behavior, mockk(), tabsTrayDialog, fab)
+
+ val bottomSheet = mockk<View>(relaxed = true)
+ every { bottomSheet.top } returns 300 // (1000 - 300) / 1000 = 0.7
+
+ callback.onSlide(bottomSheet, 1f)
+
+ verify(exactly = 0) { tabsTrayDialog.window?.setDimAmount(any()) }
+ }
+
+ @Test
+ fun `GIVEN converted dim value is at max dim WHEN onSlide is called THEN it does not set the dialog dim amount`() {
+ val behavior = mockk<BottomSheetBehavior<ConstraintLayout>>(relaxed = true)
+
+ val tabsTrayDialog = mockk<TabsTrayDialog>(relaxed = true)
+
+ val rootView = mockk<View>(relaxed = true)
+ every { rootView.bottom } returns 1000
+ val fab = mockk<ExtendedFloatingActionButton>(relaxed = true)
+ every { fab.rootView } returns rootView
+
+ val callback = TraySheetBehaviorCallback(behavior, mockk(), tabsTrayDialog, fab)
+
+ val bottomSheet = mockk<View>(relaxed = true)
+ every { bottomSheet.top } returns 400 // (1000 - 400) / 1000 = 0.6
+
+ callback.onSlide(bottomSheet, 1f)
+
+ verify(exactly = 0) { tabsTrayDialog.window?.setDimAmount(any()) }
+ }
+
+ @Test
+ fun `GIVEN converted dim value is less than max dim WHEN onSlide is called THEN it sets the dialog dim amount`() {
+ val behavior = mockk<BottomSheetBehavior<ConstraintLayout>>(relaxed = true)
+
+ val tabsTrayDialog = mockk<TabsTrayDialog>(relaxed = true)
+
+ val rootView = mockk<View>(relaxed = true)
+ every { rootView.bottom } returns 1000
+ val fab = mockk<ExtendedFloatingActionButton>(relaxed = true)
+ every { fab.rootView } returns rootView
+
+ val callback = TraySheetBehaviorCallback(behavior, mockk(), tabsTrayDialog, fab)
+
+ val bottomSheet = mockk<View>(relaxed = true)
+ every { bottomSheet.top } returns 500 // (1000 - 500) / 1000 = 0.5
+
+ callback.onSlide(bottomSheet, 1f)
+
+ verify(exactly = 1) { tabsTrayDialog.window?.setDimAmount(any()) }
+ }
+
+ @Test
+ fun `GIVEN behaviour state is 'dragging' & draggedLowestSheetTop is null WHEN onSlide is called THEN draggedLowestSheetTop is set to currentBottomSheetTop`() {
+ val behavior = mockk<BottomSheetBehavior<ConstraintLayout>>(relaxed = true)
+ every { behavior.state } returns STATE_DRAGGING
+
+ val tabsTrayDialog = mockk<TabsTrayDialog>(relaxed = true)
+
+ val rootView = mockk<View>(relaxed = true)
+ every { rootView.bottom } returns 1000
+ val fab = mockk<ExtendedFloatingActionButton>(relaxed = true)
+ every { fab.rootView } returns rootView
+ every { fab.top } returns 900
+
+ val callback = TraySheetBehaviorCallback(behavior, mockk(), tabsTrayDialog, fab)
+
+ val bottomSheetTop = 10
+
+ // draggedLowestSheetTop is null
+ val bottomSheet = mockk<View>(relaxed = true)
+ every { bottomSheet.top } returns bottomSheetTop
+ callback.onSlide(bottomSheet, 1f)
+ verify { fab.y = 900f }
+
+ assertEquals(bottomSheetTop, callback.draggedLowestSheetTop)
+ }
+
+ @Test
+ fun `GIVEN behaviour state is 'dragging' & currentBottomSheetTop is same as draggedLowestSheetTop WHEN onSlide is called THEN draggedLowestSheetTop is same value`() {
+ val behavior = mockk<BottomSheetBehavior<ConstraintLayout>>(relaxed = true)
+ every { behavior.state } returns STATE_DRAGGING
+
+ val tabsTrayDialog = mockk<TabsTrayDialog>(relaxed = true)
+
+ val rootView = mockk<View>(relaxed = true)
+ every { rootView.bottom } returns 1000
+ val fab = mockk<ExtendedFloatingActionButton>(relaxed = true)
+ every { fab.rootView } returns rootView
+ every { fab.top } returns 900
+
+ val callback = TraySheetBehaviorCallback(behavior, mockk(), tabsTrayDialog, fab)
+
+ val bottomSheetTop = 10
+
+ // draggedLowestSheetTop is null
+ val bottomSheet1 = mockk<View>(relaxed = true)
+ every { bottomSheet1.top } returns bottomSheetTop
+ callback.onSlide(bottomSheet1, 1f)
+ verify { fab.y = 900f }
+
+ // currentBottomSheetTop is same as draggedLowestSheetTop
+ val bottomSheet2 = mockk<View>(relaxed = true)
+ every { bottomSheet2.top } returns bottomSheetTop
+ callback.onSlide(bottomSheet2, 1f)
+ verify { fab.y = 900f }
+
+ assertEquals(bottomSheetTop, callback.draggedLowestSheetTop)
+ }
+
+ @Test
+ fun `GIVEN behaviour state is 'dragging' & currentBottomSheetTop is more than draggedLowestSheetTop WHEN onSlide is called THEN draggedLowestSheetTop is same value`() {
+ val behavior = mockk<BottomSheetBehavior<ConstraintLayout>>(relaxed = true)
+ every { behavior.state } returns STATE_DRAGGING
+
+ val tabsTrayDialog = mockk<TabsTrayDialog>(relaxed = true)
+
+ val rootView = mockk<View>(relaxed = true)
+ every { rootView.bottom } returns 1000
+ val fab = mockk<ExtendedFloatingActionButton>(relaxed = true)
+ every { fab.rootView } returns rootView
+ every { fab.top } returns 900
+
+ val callback = TraySheetBehaviorCallback(behavior, mockk(), tabsTrayDialog, fab)
+
+ val originalBottomSheetTop = 10
+
+ // draggedLowestSheetTop is null
+ val bottomSheet1 = mockk<View>(relaxed = true)
+ every { bottomSheet1.top } returns originalBottomSheetTop
+ callback.onSlide(bottomSheet1, 1f)
+ verify { fab.y = 900f }
+
+ // currentBottomSheetTop is same as draggedLowestSheetTop
+ val newBottomSheetTop = originalBottomSheetTop + 1
+ val bottomSheet2 = mockk<View>(relaxed = true)
+ every { bottomSheet2.top } returns newBottomSheetTop
+ callback.onSlide(bottomSheet2, 1f)
+ verify { fab.y = 901f }
+
+ assertEquals(originalBottomSheetTop, callback.draggedLowestSheetTop)
+ }
+
+ @Test
+ fun `GIVEN behaviour state is 'dragging' & currentBottomSheetTop less than draggedLowestSheetTop WHEN onSlide is called THEN draggedLowestSheetTop is set to currentBottomSheetTop`() {
+ val behavior = mockk<BottomSheetBehavior<ConstraintLayout>>(relaxed = true)
+ every { behavior.state } returns STATE_DRAGGING
+
+ val tabsTrayDialog = mockk<TabsTrayDialog>(relaxed = true)
+
+ val rootView = mockk<View>(relaxed = true)
+ every { rootView.bottom } returns 1000
+ val fab = mockk<ExtendedFloatingActionButton>(relaxed = true)
+ every { fab.rootView } returns rootView
+ every { fab.top } returns 900
+
+ val callback = TraySheetBehaviorCallback(behavior, mockk(), tabsTrayDialog, fab)
+
+ // draggedLowestSheetTop is null
+ val bottomSheet1 = mockk<View>(relaxed = true)
+ every { bottomSheet1.top } returns 10
+ callback.onSlide(bottomSheet1, 1f)
+ verify { fab.y = 900f }
+
+ // currentBottomSheetTop is less than draggedLowestSheetTop
+ val newBottomSheetTop = 9
+ val bottomSheet2 = mockk<View>(relaxed = true)
+ every { bottomSheet2.top } returns newBottomSheetTop
+ callback.onSlide(bottomSheet2, 1f)
+ verify { fab.y = 900f }
+
+ assertEquals(newBottomSheetTop, callback.draggedLowestSheetTop)
+ }
+
+ @Test
+ fun `GIVEN behaviour state is 'settling' & draggedLowestSheetTop is null WHEN onSlide is called THEN draggedLowestSheetTop is set to fab y is set`() {
+ val behavior = mockk<BottomSheetBehavior<ConstraintLayout>>(relaxed = true)
+ every { behavior.state } returns STATE_SETTLING
+
+ val tabsTrayDialog = mockk<TabsTrayDialog>(relaxed = true)
+
+ val rootView = mockk<View>(relaxed = true)
+ every { rootView.bottom } returns 1000
+ val fab = mockk<ExtendedFloatingActionButton>(relaxed = true)
+ every { fab.rootView } returns rootView
+ every { fab.top } returns 900
+
+ val callback = TraySheetBehaviorCallback(behavior, mockk(), tabsTrayDialog, fab)
+
+ // draggedLowestSheetTop is null
+ val bottomSheet = mockk<View>(relaxed = true)
+ every { bottomSheet.top } returns 10
+ callback.onSlide(bottomSheet, 1f)
+ verify { fab.y = 900f }
+
+ assertNull(callback.draggedLowestSheetTop)
+ }
+
+ @Test
+ fun `GIVEN behaviour state is 'settling' & currentBottomSheetTop is same as draggedLowestSheetTop WHEN onSlide is called THEN fab y is set`() {
+ val behavior = mockk<BottomSheetBehavior<ConstraintLayout>>(relaxed = true)
+ every { behavior.state } returns STATE_SETTLING
+
+ val tabsTrayDialog = mockk<TabsTrayDialog>(relaxed = true)
+
+ val rootView = mockk<View>(relaxed = true)
+ every { rootView.bottom } returns 1000
+ val fab = mockk<ExtendedFloatingActionButton>(relaxed = true)
+ every { fab.rootView } returns rootView
+ every { fab.top } returns 900
+
+ val callback = TraySheetBehaviorCallback(behavior, mockk(), tabsTrayDialog, fab)
+
+ val bottomSheetTop = 10
+
+ // draggedLowestSheetTop is null
+ val bottomSheet1 = mockk<View>(relaxed = true)
+ every { bottomSheet1.top } returns bottomSheetTop
+ callback.onSlide(bottomSheet1, 1f)
+ verify { fab.y = 900f }
+
+ // currentBottomSheetTop is same as draggedLowestSheetTop
+ val bottomSheet2 = mockk<View>(relaxed = true)
+ every { bottomSheet2.top } returns bottomSheetTop
+ callback.onSlide(bottomSheet2, 1f)
+ verify { fab.y = 900f }
+
+ assertNull(callback.draggedLowestSheetTop)
+ }
+
+ @Test
+ fun `GIVEN behaviour state is 'settling' & currentBottomSheetTop is more than draggedLowestSheetTop WHEN onSlide is called THEN fab y is set`() {
+ val behavior = mockk<BottomSheetBehavior<ConstraintLayout>>(relaxed = true)
+ every { behavior.state } returns STATE_SETTLING
+
+ val tabsTrayDialog = mockk<TabsTrayDialog>(relaxed = true)
+
+ val rootView = mockk<View>(relaxed = true)
+ every { rootView.bottom } returns 1000
+ val fab = mockk<ExtendedFloatingActionButton>(relaxed = true)
+ every { fab.rootView } returns rootView
+ every { fab.top } returns 900
+
+ val callback = TraySheetBehaviorCallback(behavior, mockk(), tabsTrayDialog, fab)
+
+ // draggedLowestSheetTop is null
+ val bottomSheet1 = mockk<View>(relaxed = true)
+ every { bottomSheet1.top } returns 10
+ callback.onSlide(bottomSheet1, 1f)
+ verify { fab.y = 900f }
+
+ // currentBottomSheetTop is same as draggedLowestSheetTop
+ val bottomSheet2 = mockk<View>(relaxed = true)
+ every { bottomSheet2.top } returns 11
+ callback.onSlide(bottomSheet2, 1f)
+ verify { fab.y = 900f }
+
+ assertNull(callback.draggedLowestSheetTop)
+ }
+
+ @Test
+ fun `GIVEN behaviour state is 'settling' & currentBottomSheetTop less than draggedLowestSheetTop WHEN onSlide is called THEN fab y is set`() {
+ val behavior = mockk<BottomSheetBehavior<ConstraintLayout>>(relaxed = true)
+ every { behavior.state } returns STATE_SETTLING
+
+ val tabsTrayDialog = mockk<TabsTrayDialog>(relaxed = true)
+
+ val rootView = mockk<View>(relaxed = true)
+ every { rootView.bottom } returns 1000
+ val fab = mockk<ExtendedFloatingActionButton>(relaxed = true)
+ every { fab.rootView } returns rootView
+ every { fab.top } returns 900
+
+ val callback = TraySheetBehaviorCallback(behavior, mockk(), tabsTrayDialog, fab)
+
+ // draggedLowestSheetTop is null
+ val bottomSheet1 = mockk<View>(relaxed = true)
+ every { bottomSheet1.top } returns 10
+ callback.onSlide(bottomSheet1, 1f)
+ verify { fab.y = 900f }
+
+ // currentBottomSheetTop is less than draggedLowestSheetTop
+ val bottomSheet2 = mockk<View>(relaxed = true)
+ every { bottomSheet2.top } returns 9
+ callback.onSlide(bottomSheet2, 1f)
+ verify { fab.y = 900f }
+
+ assertNull(callback.draggedLowestSheetTop)
+ }
+
+ @Test
+ fun `GIVEN behaviour state is 'state expanded' WHEN onStateChanged is called THEN draggedLowestSheetTop is set to null`() {
+ val bottomSheet = mockk<View>(relaxed = true)
+ val callback = TraySheetBehaviorCallback(mockk(), mockk(), mockk(), mockk())
+
+ callback.draggedLowestSheetTop = 1
+ assertEquals(1, callback.draggedLowestSheetTop)
+
+ callback.onStateChanged(bottomSheet, STATE_EXPANDED)
+
+ assertNull(callback.draggedLowestSheetTop)
+ }
+
+ @Test
+ fun `GIVEN behaviour state is 'state collapsed' WHEN onStateChanged is called THEN draggedLowestSheetTop is set to null`() {
+ val bottomSheet = mockk<View>(relaxed = true)
+ val callback = TraySheetBehaviorCallback(mockk(), mockk(), mockk(), mockk())
+
+ callback.draggedLowestSheetTop = 1
+ assertEquals(1, callback.draggedLowestSheetTop)
+
+ callback.onStateChanged(bottomSheet, STATE_COLLAPSED)
+
+ assertNull(callback.draggedLowestSheetTop)
+ }
+
+ @Test
+ fun `WHEN TabSheetBehaviorManager is initialized THEN it caches the orientation parameter value`() {
+ val manager0 = TabSheetBehaviorManager(
+ mockk(relaxed = true),
+ Configuration.ORIENTATION_UNDEFINED,
+ 5,
+ 4,
+ mockk(),
+ )
+ assertEquals(Configuration.ORIENTATION_UNDEFINED, manager0.currentOrientation)
+
+ val manager1 = TabSheetBehaviorManager(
+ mockk(relaxed = true),
+ Configuration.ORIENTATION_PORTRAIT,
+ 5,
+ 4,
+ mockk(),
+ )
+ assertEquals(Configuration.ORIENTATION_PORTRAIT, manager1.currentOrientation)
+
+ val manager2 = TabSheetBehaviorManager(
+ mockk(relaxed = true),
+ Configuration.ORIENTATION_LANDSCAPE,
+ 5,
+ 4,
+ mockk(),
+ )
+ assertEquals(Configuration.ORIENTATION_LANDSCAPE, manager2.currentOrientation)
+ }
+
+ @Test
+ fun `GIVEN more tabs opened than the expanding limit and portrait orientation WHEN TabSheetBehaviorManager is initialized THEN the behavior is set as expanded`() {
+ val behavior = BottomSheetBehavior<ConstraintLayout>()
+
+ TabSheetBehaviorManager(
+ behavior,
+ Configuration.ORIENTATION_PORTRAIT,
+ 5,
+ 4,
+ mockk(),
+ )
+
+ assertEquals(STATE_EXPANDED, behavior.state)
+ }
+
+ @Test
+ fun `GIVEN the number of tabs opened is exactly the expanding limit and portrait orientation WHEN TabSheetBehaviorManager is initialized THEN the behavior is set as expanded`() {
+ val behavior = BottomSheetBehavior<ConstraintLayout>()
+
+ TabSheetBehaviorManager(
+ behavior,
+ Configuration.ORIENTATION_PORTRAIT,
+ 5,
+ 5,
+ mockk(),
+ )
+
+ assertEquals(STATE_EXPANDED, behavior.state)
+ }
+
+ @Test
+ fun `GIVEN fewer tabs opened than the expanding limit and portrait orientation WHEN TabSheetBehaviorManager is initialized THEN the behavior is set as collapsed`() {
+ val behavior = BottomSheetBehavior<ConstraintLayout>()
+
+ TabSheetBehaviorManager(
+ behavior,
+ Configuration.ORIENTATION_PORTRAIT,
+ 4,
+ 5,
+ mockk(),
+ )
+
+ assertEquals(STATE_COLLAPSED, behavior.state)
+ }
+
+ @Test
+ fun `GIVEN more tabs opened than the expanding limit and undefined orientation WHEN TabSheetBehaviorManager is initialized THEN the behavior is set as expanded`() {
+ val behavior = BottomSheetBehavior<ConstraintLayout>()
+
+ TabSheetBehaviorManager(
+ behavior,
+ Configuration.ORIENTATION_UNDEFINED,
+ 5,
+ 4,
+ mockk(),
+ )
+
+ assertEquals(STATE_EXPANDED, behavior.state)
+ }
+
+ @Test
+ fun `GIVEN the number of tabs opened is exactly the expanding limit and undefined orientation WHEN TabSheetBehaviorManager is initialized THEN the behavior is set as expanded`() {
+ val behavior = BottomSheetBehavior<ConstraintLayout>()
+
+ TabSheetBehaviorManager(
+ behavior,
+ Configuration.ORIENTATION_UNDEFINED,
+ 5,
+ 5,
+ mockk(),
+ )
+
+ assertEquals(STATE_EXPANDED, behavior.state)
+ }
+
+ @Test
+ fun `GIVEN fewer tabs opened than the expanding limit and undefined orientation WHEN TabSheetBehaviorManager is initialized THEN the behavior is set as collapsed`() {
+ val behavior = BottomSheetBehavior<ConstraintLayout>()
+
+ TabSheetBehaviorManager(
+ behavior,
+ Configuration.ORIENTATION_UNDEFINED,
+ 4,
+ 5,
+ mockk(),
+ )
+
+ assertEquals(STATE_COLLAPSED, behavior.state)
+ }
+
+ @Test
+ fun `GIVEN more tabs opened than the expanding limit and landscape orientation WHEN TabSheetBehaviorManager is initialized THEN the behavior is set as expanded`() {
+ val behavior = BottomSheetBehavior<ConstraintLayout>()
+
+ TabSheetBehaviorManager(
+ behavior,
+ Configuration.ORIENTATION_LANDSCAPE,
+ 5,
+ 4,
+ mockk(),
+ )
+
+ assertEquals(STATE_EXPANDED, behavior.state)
+ }
+
+ @Test
+ fun `GIVEN the number of tabs opened is exactly the expanding limit and landscape orientation WHEN TabSheetBehaviorManager is initialized THEN the behavior is set as expanded`() {
+ val behavior = BottomSheetBehavior<ConstraintLayout>()
+
+ TabSheetBehaviorManager(
+ behavior,
+ Configuration.ORIENTATION_LANDSCAPE,
+ 5,
+ 5,
+ mockk(),
+ )
+
+ assertEquals(STATE_EXPANDED, behavior.state)
+ }
+
+ @Test
+ fun `GIVEN fewer tabs opened than the expanding limit and landscape orientation WHEN TabSheetBehaviorManager is initialized THEN the behavior is set as expanded`() {
+ val behavior = BottomSheetBehavior<ConstraintLayout>()
+
+ TabSheetBehaviorManager(
+ behavior,
+ Configuration.ORIENTATION_LANDSCAPE,
+ 4,
+ 5,
+ mockk(),
+ )
+
+ assertEquals(STATE_EXPANDED, behavior.state)
+ }
+
+ @Test
+ fun `GIVEN more tabs opened than the expanding limit and not landscape orientation WHEN updateBehaviorState is called THEN the behavior is set as expanded`() {
+ val behavior = BottomSheetBehavior<ConstraintLayout>()
+ val manager = TabSheetBehaviorManager(
+ behavior,
+ Configuration.ORIENTATION_UNDEFINED,
+ 5,
+ 4,
+ mockk(),
+ )
+
+ manager.updateBehaviorState(false)
+
+ assertEquals(STATE_EXPANDED, behavior.state)
+ }
+
+ @Test
+ fun `GIVEN the number of tabs opened is exactly the expanding limit and portrait orientation WHEN updateBehaviorState is called THEN the behavior is set as expanded`() {
+ val behavior = BottomSheetBehavior<ConstraintLayout>()
+ val manager = TabSheetBehaviorManager(
+ behavior,
+ Configuration.ORIENTATION_UNDEFINED,
+ 5,
+ 5,
+ mockk(),
+ )
+
+ manager.updateBehaviorState(false)
+
+ assertEquals(STATE_EXPANDED, behavior.state)
+ }
+
+ @Test
+ fun `GIVEN fewer tabs opened than the expanding limit and portrait orientation WHEN updateBehaviorState is called THEN the behavior is set as collapsed`() {
+ val behavior = BottomSheetBehavior<ConstraintLayout>()
+ val manager = TabSheetBehaviorManager(
+ behavior,
+ Configuration.ORIENTATION_UNDEFINED,
+ 4,
+ 5,
+ mockk(),
+ )
+
+ manager.updateBehaviorState(false)
+
+ assertEquals(STATE_COLLAPSED, behavior.state)
+ }
+
+ @Test
+ fun `GIVEN more tabs opened than the expanding limit and landscape orientation WHEN updateBehaviorState is called THEN the behavior is set as expanded`() {
+ val behavior = BottomSheetBehavior<ConstraintLayout>()
+ val manager = TabSheetBehaviorManager(
+ behavior,
+ Configuration.ORIENTATION_UNDEFINED,
+ 5,
+ 4,
+ mockk(),
+ )
+
+ manager.updateBehaviorState(true)
+
+ assertEquals(STATE_EXPANDED, behavior.state)
+ }
+
+ @Test
+ fun `GIVEN the number of tabs opened is exactly the expanding limit and landscape orientation WHEN updateBehaviorState is called THEN the behavior is set as expanded`() {
+ val behavior = BottomSheetBehavior<ConstraintLayout>()
+ val manager = TabSheetBehaviorManager(
+ behavior,
+ Configuration.ORIENTATION_UNDEFINED,
+ 5,
+ 5,
+ mockk(),
+ )
+
+ manager.updateBehaviorState(true)
+
+ assertEquals(STATE_EXPANDED, behavior.state)
+ }
+
+ @Test
+ fun `GIVEN fewer tabs opened than the expanding limit and landscape orientation WHEN updateBehaviorState is called THEN the behavior is set as expanded`() {
+ val behavior = BottomSheetBehavior<ConstraintLayout>()
+ val manager = TabSheetBehaviorManager(
+ behavior,
+ Configuration.ORIENTATION_UNDEFINED,
+ 4,
+ 5,
+ mockk(),
+ )
+
+ manager.updateBehaviorState(true)
+
+ assertEquals(STATE_EXPANDED, behavior.state)
+ }
+
+ @Test
+ fun `WHEN updateDependingOnOrientation is called with the same orientation as the current one THEN nothing happens`() {
+ val manager = spyk(
+ TabSheetBehaviorManager(
+ mockk(relaxed = true),
+ Configuration.ORIENTATION_PORTRAIT,
+ 4,
+ 5,
+ mockk(),
+ ),
+ )
+
+ manager.updateDependingOnOrientation(Configuration.ORIENTATION_PORTRAIT)
+
+ verify(exactly = 0) { manager.currentOrientation = any() }
+ verify(exactly = 0) { manager.updateBehaviorExpandedOffset(any()) }
+ verify(exactly = 0) { manager.updateBehaviorState(any()) }
+ }
+
+ @Test
+ fun `WHEN updateDependingOnOrientation is called with a new orientation THEN this is cached and updateBehaviorState is called`() {
+ val manager = spyk(
+ TabSheetBehaviorManager(
+ mockk(relaxed = true),
+ Configuration.ORIENTATION_PORTRAIT,
+ 4,
+ 5,
+ mockk(),
+ ),
+ )
+
+ manager.updateDependingOnOrientation(Configuration.ORIENTATION_UNDEFINED)
+ assertEquals(Configuration.ORIENTATION_UNDEFINED, manager.currentOrientation)
+ verify { manager.updateBehaviorExpandedOffset(any()) }
+ verify { manager.updateBehaviorState(any()) }
+
+ manager.updateDependingOnOrientation(Configuration.ORIENTATION_LANDSCAPE)
+ assertEquals(Configuration.ORIENTATION_LANDSCAPE, manager.currentOrientation)
+ verify(exactly = 2) { manager.updateBehaviorExpandedOffset(any()) }
+ verify(exactly = 2) { manager.updateBehaviorState(any()) }
+ }
+
+ @Test
+ fun `WHEN isLandscape is called with Configuration#ORIENTATION_LANDSCAPE THEN it returns true`() {
+ val manager = spyk(
+ TabSheetBehaviorManager(
+ mockk(relaxed = true),
+ Configuration.ORIENTATION_PORTRAIT,
+ 4,
+ 5,
+ mockk(),
+ ),
+ )
+
+ assertTrue(manager.isLandscape(Configuration.ORIENTATION_LANDSCAPE))
+ }
+
+ @Test
+ fun `WHEN isLandscape is called with Configuration#ORIENTATION_PORTRAIT THEN it returns false`() {
+ val manager = spyk(
+ TabSheetBehaviorManager(
+ mockk(relaxed = true),
+ Configuration.ORIENTATION_PORTRAIT,
+ 4,
+ 5,
+ mockk(),
+ ),
+ )
+
+ assertFalse(manager.isLandscape(Configuration.ORIENTATION_PORTRAIT))
+ }
+
+ @Test
+ fun `WHEN isLandscape is called with Configuration#ORIENTATION_UNDEFINED THEN it returns false`() {
+ val manager = spyk(
+ TabSheetBehaviorManager(
+ mockk(relaxed = true),
+ Configuration.ORIENTATION_PORTRAIT,
+ 4,
+ 5,
+ mockk(),
+ ),
+ )
+
+ assertFalse(manager.isLandscape(Configuration.ORIENTATION_UNDEFINED))
+ }
+
+ @Test
+ fun `GIVEN a behavior and landscape orientation WHEN TabSheetBehaviorManager is initialized THEN it sets the behavior expandedOffset to 0`() {
+ val behavior = BottomSheetBehavior<ConstraintLayout>()
+ // expandedOffset is only used if isFitToContents == false
+ behavior.isFitToContents = false
+ val displayMetrics: DisplayMetrics = mockk()
+
+ try {
+ mockkStatic("mozilla.components.support.ktx.android.util.DisplayMetricsKt")
+ every { EXPANDED_OFFSET_IN_LANDSCAPE_DP.dpToPx(displayMetrics) } returns EXPANDED_OFFSET_IN_LANDSCAPE_DP
+
+ TabSheetBehaviorManager(
+ behavior,
+ Configuration.ORIENTATION_LANDSCAPE,
+ 5,
+ 4,
+ displayMetrics,
+ )
+ } finally {
+ unmockkStatic("mozilla.components.support.ktx.android.util.DisplayMetricsKt")
+ }
+
+ assertEquals(0, behavior.expandedOffset)
+ }
+
+ @Test
+ fun `GIVEN a behavior and portrait orientation WHEN TabSheetBehaviorManager is initialized THEN it sets the behavior expandedOffset to 40`() {
+ val behavior = BottomSheetBehavior<ConstraintLayout>()
+ // expandedOffset is only used if isFitToContents == false
+ behavior.isFitToContents = false
+ val displayMetrics: DisplayMetrics = mockk()
+
+ try {
+ mockkStatic("mozilla.components.support.ktx.android.util.DisplayMetricsKt")
+ every { EXPANDED_OFFSET_IN_PORTRAIT_DP.dpToPx(displayMetrics) } returns EXPANDED_OFFSET_IN_PORTRAIT_DP
+
+ TabSheetBehaviorManager(
+ behavior,
+ Configuration.ORIENTATION_PORTRAIT,
+ 5,
+ 4,
+ displayMetrics,
+ )
+ } finally {
+ unmockkStatic("mozilla.components.support.ktx.android.util.DisplayMetricsKt")
+ }
+
+ assertEquals(40, behavior.expandedOffset)
+ }
+
+ @Test
+ fun `GIVEN a behavior and undefined orientation WHEN TabSheetBehaviorManager is initialized THEN it sets the behavior expandedOffset to 40`() {
+ val behavior = BottomSheetBehavior<ConstraintLayout>()
+ // expandedOffset is only used if isFitToContents == false
+ behavior.isFitToContents = false
+ val displayMetrics: DisplayMetrics = mockk()
+
+ try {
+ mockkStatic("mozilla.components.support.ktx.android.util.DisplayMetricsKt")
+ every { EXPANDED_OFFSET_IN_PORTRAIT_DP.dpToPx(displayMetrics) } returns EXPANDED_OFFSET_IN_PORTRAIT_DP
+
+ TabSheetBehaviorManager(
+ behavior,
+ Configuration.ORIENTATION_UNDEFINED,
+ 5,
+ 4,
+ displayMetrics,
+ )
+ } finally {
+ unmockkStatic("mozilla.components.support.ktx.android.util.DisplayMetricsKt")
+ }
+
+ assertEquals(40, behavior.expandedOffset)
+ }
+
+ @Test
+ fun `WHEN updateBehaviorExpandedOffset is called with a portrait parameter THEN it sets expandedOffset to be 40 dp`() {
+ val behavior = BottomSheetBehavior<ConstraintLayout>()
+ // expandedOffset is only used if isFitToContents == false
+ behavior.isFitToContents = false
+ val displayMetrics: DisplayMetrics = mockk()
+
+ try {
+ mockkStatic("mozilla.components.support.ktx.android.util.DisplayMetricsKt")
+ every { EXPANDED_OFFSET_IN_PORTRAIT_DP.dpToPx(displayMetrics) } returns EXPANDED_OFFSET_IN_PORTRAIT_DP
+ val manager = TabSheetBehaviorManager(
+ behavior,
+ Configuration.ORIENTATION_LANDSCAPE,
+ 5,
+ 4,
+ displayMetrics,
+ )
+
+ manager.updateDependingOnOrientation(Configuration.ORIENTATION_PORTRAIT)
+ } finally {
+ unmockkStatic("mozilla.components.support.ktx.android.util.DisplayMetricsKt")
+ }
+
+ assertEquals(40, behavior.expandedOffset)
+ }
+
+ @Test
+ fun `WHEN updateBehaviorExpandedOffset is called with a undefined parameter THEN it sets expandedOffset to be 40 dp`() {
+ val behavior = BottomSheetBehavior<ConstraintLayout>()
+ // expandedOffset is only used if isFitToContents == false
+ behavior.isFitToContents = false
+ val displayMetrics: DisplayMetrics = mockk()
+
+ try {
+ mockkStatic("mozilla.components.support.ktx.android.util.DisplayMetricsKt")
+ every { EXPANDED_OFFSET_IN_PORTRAIT_DP.dpToPx(displayMetrics) } returns EXPANDED_OFFSET_IN_PORTRAIT_DP
+ val manager = TabSheetBehaviorManager(
+ behavior,
+ Configuration.ORIENTATION_LANDSCAPE,
+ 5,
+ 4,
+ displayMetrics,
+ )
+
+ manager.updateDependingOnOrientation(Configuration.ORIENTATION_UNDEFINED)
+ } finally {
+ unmockkStatic("mozilla.components.support.ktx.android.util.DisplayMetricsKt")
+ }
+
+ assertEquals(40, behavior.expandedOffset)
+ }
+
+ @Test
+ fun `WHEN updateBehaviorExpandedOffset is called with a landscape parameter THEN it sets expandedOffset to be 0 dp`() {
+ val behavior = BottomSheetBehavior<ConstraintLayout>()
+ // expandedOffset is only used if isFitToContents == false
+ behavior.isFitToContents = false
+ val displayMetrics: DisplayMetrics = mockk()
+
+ try {
+ mockkStatic("mozilla.components.support.ktx.android.util.DisplayMetricsKt")
+ every { EXPANDED_OFFSET_IN_LANDSCAPE_DP.dpToPx(displayMetrics) } returns EXPANDED_OFFSET_IN_LANDSCAPE_DP
+ val manager = TabSheetBehaviorManager(
+ behavior,
+ Configuration.ORIENTATION_UNDEFINED,
+ 5,
+ 4,
+ displayMetrics,
+ )
+
+ manager.updateDependingOnOrientation(Configuration.ORIENTATION_LANDSCAPE)
+ } finally {
+ unmockkStatic("mozilla.components.support.ktx.android.util.DisplayMetricsKt")
+ }
+
+ assertEquals(0, behavior.expandedOffset)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayDialogTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayDialogTest.kt
new file mode 100644
index 0000000000..075710daee
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayDialogTest.kt
@@ -0,0 +1,24 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray
+
+import android.content.Context
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Test
+
+class TabsTrayDialogTest {
+ @Test
+ fun `WHEN onBackPressed THEN invoke interactor`() {
+ val context = mockk<Context>(relaxed = true)
+ val interactor = mockk<TabsTrayInteractor>(relaxed = true)
+ val dialog = TabsTrayDialog(context, 0) { interactor }
+
+ @Suppress("DEPRECATION")
+ dialog.onBackPressed()
+
+ verify { interactor.onBackPressed() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayFragmentTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayFragmentTest.kt
new file mode 100644
index 0000000000..746859b91a
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayFragmentTest.kt
@@ -0,0 +1,429 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray
+
+import android.content.Context
+import android.content.res.Configuration
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.core.view.isVisible
+import androidx.lifecycle.LifecycleCoroutineScope
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import androidx.navigation.NavController
+import androidx.navigation.fragment.findNavController
+import androidx.viewbinding.ViewBindings
+import androidx.viewpager2.widget.ViewPager2
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.tabs.TabLayout
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.spyk
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import kotlinx.coroutines.CoroutineScope
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.TabsTray
+import org.mozilla.fenix.NavGraphDirections
+import org.mozilla.fenix.R
+import org.mozilla.fenix.databinding.ComponentTabstray2Binding
+import org.mozilla.fenix.databinding.ComponentTabstrayFabBinding
+import org.mozilla.fenix.databinding.FragmentTabTrayDialogBinding
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.helpers.MockkRetryTestRule
+import org.mozilla.fenix.home.HomeScreenViewModel
+import org.mozilla.fenix.tabstray.ext.showWithTheme
+import org.mozilla.fenix.utils.allowUndo
+
+@RunWith(FenixRobolectricTestRunner::class)
+class TabsTrayFragmentTest {
+ private lateinit var context: Context
+ private lateinit var view: ViewGroup
+ private lateinit var fragment: TabsTrayFragment
+ private lateinit var tabsTrayBinding: ComponentTabstray2Binding
+ private lateinit var tabsTrayDialogBinding: FragmentTabTrayDialogBinding
+ private lateinit var fabButtonBinding: ComponentTabstrayFabBinding
+
+ @get:Rule
+ val mockkRule = MockkRetryTestRule()
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @Before
+ fun setup() {
+ context = mockk(relaxed = true)
+ view = mockk(relaxed = true)
+ val inflater = LayoutInflater.from(testContext)
+ tabsTrayDialogBinding = FragmentTabTrayDialogBinding.inflate(inflater)
+ tabsTrayBinding = ComponentTabstray2Binding.inflate(inflater)
+ fabButtonBinding = ComponentTabstrayFabBinding.inflate(inflater)
+
+ fragment = spyk(TabsTrayFragment())
+ fragment._tabsTrayBinding = tabsTrayBinding
+ fragment._tabsTrayDialogBinding = tabsTrayDialogBinding
+ fragment._fabButtonBinding = fabButtonBinding
+ every { fragment.context } returns context
+ every { fragment.context } returns context
+ every { fragment.viewLifecycleOwner } returns mockk(relaxed = true)
+ }
+
+ @Test
+ fun `WHEN showUndoSnackbarForTab is called for a private tab with new tab button visible THEN an appropriate snackbar is shown`() {
+ try {
+ mockkStatic("org.mozilla.fenix.utils.UndoKt")
+ mockkStatic("androidx.lifecycle.LifecycleOwnerKt")
+ val lifecycleScope: LifecycleCoroutineScope = mockk(relaxed = true)
+ every { any<LifecycleOwner>().lifecycleScope } returns lifecycleScope
+ fabButtonBinding.newTabButton.isVisible = true
+ every { fragment.context } returns testContext // needed for getString()
+ every { any<CoroutineScope>().allowUndo(any(), any(), any(), any(), any(), any(), any(), any()) } just Runs
+ every { fragment.requireView() } returns view
+ every { testContext.settings().enableTabsTrayToCompose } returns false
+
+ fragment.showUndoSnackbarForTab(true)
+
+ verify {
+ lifecycleScope.allowUndo(
+ view,
+ testContext.getString(R.string.snackbar_private_tab_closed),
+ testContext.getString(R.string.snackbar_deleted_undo),
+ any(),
+ any(),
+ fabButtonBinding.newTabButton,
+ TabsTrayFragment.ELEVATION,
+ false,
+ )
+ }
+ } finally {
+ unmockkStatic("org.mozilla.fenix.utils.UndoKt")
+ unmockkStatic("androidx.lifecycle.LifecycleOwnerKt")
+ }
+ }
+
+ @Test
+ fun `WHEN showUndoSnackbarForTab is called for a private tab with new tab button not visible THEN an appropriate snackbar is shown`() {
+ try {
+ mockkStatic("org.mozilla.fenix.utils.UndoKt")
+ mockkStatic("androidx.lifecycle.LifecycleOwnerKt")
+ val lifecycleScope: LifecycleCoroutineScope = mockk(relaxed = true)
+ every { any<LifecycleOwner>().lifecycleScope } returns lifecycleScope
+ every { fragment.context } returns testContext // needed for getString()
+ every { any<CoroutineScope>().allowUndo(any(), any(), any(), any(), any(), any(), any(), any()) } just Runs
+ every { fragment.requireView() } returns view
+ every { testContext.settings().enableTabsTrayToCompose } returns false
+
+ fragment.showUndoSnackbarForTab(true)
+
+ verify {
+ lifecycleScope.allowUndo(
+ view,
+ testContext.getString(R.string.snackbar_private_tab_closed),
+ testContext.getString(R.string.snackbar_deleted_undo),
+ any(),
+ any(),
+ null,
+ TabsTrayFragment.ELEVATION,
+ false,
+ )
+ }
+ } finally {
+ unmockkStatic("org.mozilla.fenix.utils.UndoKt")
+ unmockkStatic("androidx.lifecycle.LifecycleOwnerKt")
+ }
+ }
+
+ @Test
+ fun `WHEN showUndoSnackbarForTab is called for a normal tab with new tab button visible THEN an appropriate snackbar is shown`() {
+ try {
+ mockkStatic("org.mozilla.fenix.utils.UndoKt")
+ mockkStatic("androidx.lifecycle.LifecycleOwnerKt")
+ val lifecycleScope: LifecycleCoroutineScope = mockk(relaxed = true)
+ every { any<LifecycleOwner>().lifecycleScope } returns lifecycleScope
+ fabButtonBinding.newTabButton.isVisible = true
+ every { fragment.context } returns testContext // needed for getString()
+ every { any<CoroutineScope>().allowUndo(any(), any(), any(), any(), any(), any(), any(), any()) } just Runs
+ every { fragment.requireView() } returns view
+ every { testContext.settings().enableTabsTrayToCompose } returns false
+
+ fragment.showUndoSnackbarForTab(false)
+
+ verify {
+ lifecycleScope.allowUndo(
+ view,
+ testContext.getString(R.string.snackbar_tab_closed),
+ testContext.getString(R.string.snackbar_deleted_undo),
+ any(),
+ any(),
+ fabButtonBinding.newTabButton,
+ TabsTrayFragment.ELEVATION,
+ false,
+ )
+ }
+ } finally {
+ unmockkStatic("org.mozilla.fenix.utils.UndoKt")
+ unmockkStatic("androidx.lifecycle.LifecycleOwnerKt")
+ }
+ }
+
+ @Test
+ fun `WHEN showUndoSnackbarForTab is called for a normal tab with new tab button not visible THEN an appropriate snackbar is shown`() {
+ try {
+ mockkStatic("org.mozilla.fenix.utils.UndoKt")
+ mockkStatic("androidx.lifecycle.LifecycleOwnerKt")
+ val lifecycleScope: LifecycleCoroutineScope = mockk(relaxed = true)
+ every { any<LifecycleOwner>().lifecycleScope } returns lifecycleScope
+ every { fragment.context } returns testContext // needed for getString()
+ every { any<CoroutineScope>().allowUndo(any(), any(), any(), any(), any(), any(), any(), any()) } just Runs
+ every { fragment.requireView() } returns view
+ every { testContext.settings().enableTabsTrayToCompose } returns false
+
+ fragment.showUndoSnackbarForTab(false)
+
+ verify {
+ lifecycleScope.allowUndo(
+ view,
+ testContext.getString(R.string.snackbar_tab_closed),
+ testContext.getString(R.string.snackbar_deleted_undo),
+ any(),
+ any(),
+ null,
+ TabsTrayFragment.ELEVATION,
+ false,
+ )
+ }
+ } finally {
+ unmockkStatic("org.mozilla.fenix.utils.UndoKt")
+ unmockkStatic("androidx.lifecycle.LifecycleOwnerKt")
+ }
+ }
+
+ @Test
+ fun `WHEN setupPager is called THEN it sets the tray adapter and disables user initiated scrolling`() {
+ val store: TabsTrayStore = mockk()
+ val lifecycleOwner = mockk<LifecycleOwner>(relaxed = true)
+ val trayInteractor: TabsTrayInteractor = mockk()
+ val browserStore: BrowserStore = mockk()
+ every { context.components.core.store } returns browserStore
+
+ fragment.setupPager(
+ context = context,
+ lifecycleOwner = lifecycleOwner,
+ store = store,
+ trayInteractor = trayInteractor,
+ )
+
+ val adapter = (tabsTrayBinding.tabsTray.adapter as TrayPagerAdapter)
+ assertSame(context, adapter.context)
+ assertSame(lifecycleOwner, adapter.lifecycleOwner)
+ assertSame(store, adapter.tabsTrayStore)
+ assertSame(trayInteractor, adapter.interactor)
+ assertSame(browserStore, adapter.browserStore)
+ assertFalse(tabsTrayBinding.tabsTray.isUserInputEnabled)
+ }
+
+ @Test
+ fun `WHEN setupMenu is called THEN it sets a 3 dot menu click listener to open the tray menu`() {
+ try {
+ mockkStatic("org.mozilla.fenix.tabstray.ext.BrowserMenuKt")
+ val navigationInteractor: NavigationInteractor = mockk()
+ every { context.components.core.store } returns mockk()
+ every { fragment.tabsTrayStore } returns mockk()
+ val menu: BrowserMenu = mockk {
+ every { showWithTheme(any()) } just Runs
+ }
+ val menuBuilder: MenuIntegration = mockk(relaxed = true) {
+ every { build() } returns menu
+ }
+ every { fragment.getTrayMenu(any(), any(), any(), any(), any()) } returns menuBuilder
+
+ assertNull(TabsTray.menuOpened.testGetValue())
+
+ fragment.setupMenu(navigationInteractor)
+ tabsTrayBinding.tabTrayOverflow.performClick()
+
+ assertNotNull(TabsTray.menuOpened.testGetValue())
+ verify { menuBuilder.build() }
+ verify { menu.showWithTheme(tabsTrayBinding.tabTrayOverflow) }
+ } finally {
+ unmockkStatic("org.mozilla.fenix.tabstray.ext.BrowserMenuKt")
+ }
+ }
+
+ @Test
+ fun `WHEN getTrayMenu is called THEN it returns a MenuIntegration initialized with the passed in parameters`() {
+ val browserStore: BrowserStore = mockk()
+ val tabsTrayStore: TabsTrayStore = mockk()
+ val tabLayout: TabLayout = mockk()
+ val navigationInteractor: NavigationInteractor = mockk()
+
+ val result = fragment.getTrayMenu(context, browserStore, tabsTrayStore, tabLayout, navigationInteractor)
+
+ assertSame(context, result.context)
+ assertSame(browserStore, result.browserStore)
+ assertSame(tabsTrayStore, result.tabsTrayStore)
+ assertSame(tabLayout, result.tabLayout)
+ assertSame(navigationInteractor, result.navigationInteractor)
+ }
+
+ @Test
+ fun `WHEN setupBackgroundDismissalListener is called THEN it sets a click listener for tray's tabLayout and handle`() {
+ var clickCount = 0
+ val callback: (View) -> Unit = { clickCount++ }
+ every { fragment.context } returns testContext
+ every { testContext.settings().enableTabsTrayToCompose } returns false
+
+ fragment.setupBackgroundDismissalListener(callback)
+
+ tabsTrayDialogBinding.tabLayout.performClick()
+ assertEquals(1, clickCount)
+ tabsTrayBinding.handle.performClick()
+ assertEquals(2, clickCount)
+ }
+
+ @Test
+ fun `WHEN dismissTabsTrayAndNavigateHome is called with a sessionId THEN it navigates to home to delete that sessions and dismisses the tray`() {
+ every { fragment.navigateToHomeAndDeleteSession(any()) } just Runs
+ every { fragment.dismissTabsTray() } just Runs
+
+ fragment.dismissTabsTrayAndNavigateHome("test")
+
+ verify { fragment.navigateToHomeAndDeleteSession("test") }
+ verify { fragment.dismissTabsTray() }
+ }
+
+ @Test
+ fun `WHEN navigateToHomeAndDeleteSession is called with a sessionId THEN it navigates to home and transmits there the sessionId`() {
+ try {
+ mockkStatic("androidx.fragment.app.FragmentViewModelLazyKt")
+ mockkStatic("androidx.navigation.fragment.FragmentKt")
+ mockkStatic("org.mozilla.fenix.ext.NavControllerKt")
+ val viewModel: HomeScreenViewModel = mockk(relaxed = true)
+ every { fragment.homeViewModel } returns viewModel
+ val navController: NavController = mockk(relaxed = true)
+ every { fragment.findNavController() } returns navController
+
+ fragment.navigateToHomeAndDeleteSession("test")
+
+ verify { viewModel.sessionToDelete = "test" }
+ verify { navController.navigate(NavGraphDirections.actionGlobalHome()) }
+ } finally {
+ unmockkStatic("org.mozilla.fenix.ext.NavControllerKt")
+ unmockkStatic("androidx.navigation.fragment.FragmentKt")
+ unmockkStatic("androidx.fragment.app.FragmentViewModelLazyKt")
+ }
+ }
+
+ @Test
+ fun `WHEN selectTabPosition is called with a position and smooth scroll indication THEN it scrolls to that tab and selects it`() {
+ val tabsTray: ViewPager2 = mockk(relaxed = true)
+ val tab: TabLayout.Tab = mockk(relaxed = true)
+ val tabLayout: TabLayout = mockk {
+ every { getTabAt(any()) } returns tab
+ }
+
+ every { fragment.context } returns testContext
+ every { testContext.settings().enableTabsTrayToCompose } returns false
+
+ mockkStatic(ViewBindings::class) {
+ every { ViewBindings.findChildViewById<View>(tabsTrayBinding.root, tabsTrayBinding.tabsTray.id) } returns tabsTray
+ every { ViewBindings.findChildViewById<View>(tabsTrayBinding.root, tabsTrayBinding.tabLayout.id) } returns tabLayout
+
+ tabsTrayBinding = ComponentTabstray2Binding.bind(tabsTrayBinding.root)
+ fragment._tabsTrayBinding = tabsTrayBinding
+
+ fragment.selectTabPosition(2, true)
+
+ verify { tabsTray.setCurrentItem(2, true) }
+ verify { tab.select() }
+ }
+ }
+
+ @Test
+ fun `WHEN dismissTabsTray is called THEN it dismisses the tray`() {
+ every { fragment.dismissAllowingStateLoss() } just Runs
+ mockkStatic("org.mozilla.fenix.ext.ContextKt") {
+ every { any<Context>().components } returns mockk(relaxed = true)
+ fragment.dismissTabsTray()
+
+ verify { fragment.dismissAllowingStateLoss() }
+ }
+ }
+
+ @Test
+ fun `WHEN onConfigurationChanged is called THEN it delegates the tray behavior manager to update the tray`() {
+ val trayBehaviorManager: TabSheetBehaviorManager = mockk(relaxed = true)
+ fragment.trayBehaviorManager = trayBehaviorManager
+ val newConfiguration = Configuration()
+ every { context.settings().gridTabView } returns false
+
+ fragment.onConfigurationChanged(newConfiguration)
+
+ verify { trayBehaviorManager.updateDependingOnOrientation(newConfiguration.orientation) }
+ }
+
+ @Test
+ fun `WHEN the tabs tray is declared in XML THEN certain options are set for the behavior`() {
+ tabsTrayBinding = ComponentTabstray2Binding.inflate(
+ LayoutInflater.from(testContext),
+ CoordinatorLayout(testContext),
+ true,
+ )
+ val behavior = BottomSheetBehavior.from(tabsTrayBinding.tabWrapper)
+
+ assertFalse(behavior.isFitToContents)
+ assertFalse(behavior.skipCollapsed)
+ assert(behavior.halfExpandedRatio <= 0.001f)
+ }
+
+ @Test
+ fun `GIVEN a grid TabView WHEN onConfigurationChanged is called THEN the adapter structure is updated`() {
+ every { context.settings().gridTabView } returns true
+ val adapter = mockk<TrayPagerAdapter>(relaxed = true)
+ tabsTrayBinding.tabsTray.adapter = adapter
+ fragment._tabsTrayBinding = tabsTrayBinding
+ val trayBehaviorManager: TabSheetBehaviorManager = mockk(relaxed = true)
+ fragment.trayBehaviorManager = trayBehaviorManager
+ val newConfiguration = Configuration()
+
+ fragment.onConfigurationChanged(newConfiguration)
+
+ verify { adapter.notifyDataSetChanged() }
+ }
+
+ @Test
+ fun `GIVEN a list TabView WHEN onConfigurationChanged is called THEN the adapter structure is NOT updated`() {
+ every { context.settings().gridTabView } returns false
+ val adapter = mockk<TrayPagerAdapter>(relaxed = true)
+ tabsTrayBinding.tabsTray.adapter = adapter
+ fragment._tabsTrayBinding = tabsTrayBinding
+ val trayBehaviorManager: TabSheetBehaviorManager = mockk(relaxed = true)
+ fragment.trayBehaviorManager = trayBehaviorManager
+ val newConfiguration = Configuration()
+
+ fragment.onConfigurationChanged(newConfiguration)
+
+ verify(exactly = 0) { adapter.notifyDataSetChanged() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayInfoBannerBindingTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayInfoBannerBindingTest.kt
new file mode 100644
index 0000000000..1033b75ca4
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayInfoBannerBindingTest.kt
@@ -0,0 +1,104 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray
+
+import android.view.View.GONE
+import android.view.View.VISIBLE
+import android.view.ViewGroup
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.tabstray.TabsTrayInfoBannerBinding.Companion.TAB_COUNT_SHOW_CFR
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+@OptIn(ExperimentalCoroutinesApi::class)
+class TabsTrayInfoBannerBindingTest {
+
+ private lateinit var store: BrowserStore
+ private lateinit var view: ViewGroup
+ private lateinit var interactor: NavigationInteractor
+ private lateinit var settings: Settings
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Before
+ fun setUp() {
+ store = BrowserStore()
+ view = CoordinatorLayout(testContext)
+ interactor = mockk(relaxed = true)
+ settings = Settings(testContext)
+
+ every { testContext.components.settings } returns settings
+ }
+
+ @Test
+ fun `WHEN tab number reaches CFR count THEN banner is shown`() {
+ view.visibility = GONE
+
+ val binding =
+ TabsTrayInfoBannerBinding(
+ context = testContext,
+ store = store,
+ infoBannerView = view,
+ settings = settings,
+ navigationInteractor = interactor,
+ )
+
+ binding.start()
+ for (i in 1 until TAB_COUNT_SHOW_CFR) {
+ store.dispatch(TabListAction.AddTabAction(createTab("https://mozilla.org")))
+ store.waitUntilIdle()
+
+ assert(view.visibility == GONE)
+ }
+
+ store.dispatch(TabListAction.AddTabAction(createTab("https://mozilla.org")))
+ store.waitUntilIdle()
+ assert(view.visibility == VISIBLE)
+ }
+
+ @Test
+ fun `WHEN dismiss THEN auto close tabs info banner will not open tab settings`() {
+ view.visibility = GONE
+ settings.gridTabView = true
+
+ val binding =
+ TabsTrayInfoBannerBinding(
+ context = testContext,
+ store = store,
+ infoBannerView = view,
+ settings = settings,
+ navigationInteractor = interactor,
+ )
+
+ binding.start()
+ for (i in 1..TAB_COUNT_SHOW_CFR) {
+ store.dispatch(TabListAction.AddTabAction(createTab("https://mozilla.org")))
+ store.waitUntilIdle()
+ }
+
+ assert(view.visibility == VISIBLE)
+ binding.banner?.dismissAction?.invoke()
+
+ verify(exactly = 0) { interactor.onTabSettingsClicked() }
+ assert(!settings.shouldShowAutoCloseTabsBanner)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayMiddlewareTest.kt
new file mode 100644
index 0000000000..647d66b33d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayMiddlewareTest.kt
@@ -0,0 +1,75 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray
+
+import io.mockk.mockk
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.Metrics
+import org.mozilla.fenix.GleanMetrics.TabsTray
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class) // for gleanTestRule
+class TabsTrayMiddlewareTest {
+
+ private lateinit var store: TabsTrayStore
+ private lateinit var tabsTrayMiddleware: TabsTrayMiddleware
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @Before
+ fun setUp() {
+ tabsTrayMiddleware = TabsTrayMiddleware()
+ store = TabsTrayStore(
+ middlewares = listOf(tabsTrayMiddleware),
+ initialState = TabsTrayState(),
+ )
+ }
+
+ @Test
+ fun `WHEN inactive tabs are updated THEN report the count of inactive tabs`() {
+ assertNull(TabsTray.hasInactiveTabs.testGetValue())
+ assertNull(Metrics.inactiveTabsCount.testGetValue())
+
+ store.dispatch(TabsTrayAction.UpdateInactiveTabs(emptyList()))
+ store.waitUntilIdle()
+ assertNotNull(TabsTray.hasInactiveTabs.testGetValue())
+ assertNotNull(Metrics.inactiveTabsCount.testGetValue())
+ assertEquals(0L, Metrics.inactiveTabsCount.testGetValue())
+ }
+
+ @Test
+ fun `WHEN multi select mode from menu is entered THEN relevant metrics are collected`() {
+ assertNull(TabsTray.enterMultiselectMode.testGetValue())
+
+ store.dispatch(TabsTrayAction.EnterSelectMode)
+ store.waitUntilIdle()
+
+ assertNotNull(TabsTray.enterMultiselectMode.testGetValue())
+ val snapshot = TabsTray.enterMultiselectMode.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("false", snapshot.single().extra?.getValue("tab_selected"))
+ }
+
+ @Test
+ fun `WHEN multi select mode by long press is entered THEN relevant metrics are collected`() {
+ store.dispatch(TabsTrayAction.AddSelectTab(mockk()))
+ store.waitUntilIdle()
+
+ assertNotNull(TabsTray.enterMultiselectMode.testGetValue())
+ val snapshot = TabsTray.enterMultiselectMode.testGetValue()!!
+ assertEquals(1, snapshot.size)
+ assertEquals("true", snapshot.single().extra?.getValue("tab_selected"))
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayStateTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayStateTest.kt
new file mode 100644
index 0000000000..e146074b3d
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayStateTest.kt
@@ -0,0 +1,206 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.compose.MenuItem
+import org.mozilla.fenix.tabstray.ext.getMenuItems
+import org.mozilla.fenix.tabstray.ext.isSelect
+
+@RunWith(AndroidJUnit4::class)
+class TabsTrayStateTest {
+
+ private lateinit var store: TabsTrayStore
+
+ @Before
+ fun setup() {
+ store = TabsTrayStore()
+ }
+
+ @Test
+ fun `WHEN entering select mode THEN isSelected extension method returns true`() {
+ store.dispatch(TabsTrayAction.EnterSelectMode)
+ store.waitUntilIdle()
+
+ Assert.assertTrue(store.state.mode.isSelect())
+ }
+
+ @Test
+ fun `WHEN entering normal mode THEN isSelected extension method returns false`() {
+ store.dispatch(TabsTrayAction.ExitSelectMode)
+ store.waitUntilIdle()
+
+ Assert.assertFalse(store.state.mode.isSelect())
+ }
+
+ @Test
+ fun `GIVEN select mode is selected and show the inactive button is true WHEN entering any page THEN return 3 items`() {
+ val menuItems = initMenuItems(
+ mode = TabsTrayState.Mode.Select(emptySet()),
+ shouldShowInactiveButton = true,
+ )
+ Assert.assertEquals(menuItems.size, 3)
+ Assert.assertEquals(
+ menuItems[0].title,
+ testContext.getString(R.string.tab_tray_multiselect_menu_item_bookmark),
+ )
+ Assert.assertEquals(
+ menuItems[1].title,
+ testContext.getString(R.string.tab_tray_multiselect_menu_item_close),
+ )
+ Assert.assertEquals(
+ menuItems[2].title,
+ testContext.getString(R.string.inactive_tabs_menu_item),
+ )
+ }
+
+ @Test
+ fun `GIVEN select mode is selected and show the inactive button is false WHEN entering any page THEN return 2 menu items`() {
+ val menuItems = initMenuItems(
+ mode = TabsTrayState.Mode.Select(emptySet()),
+ )
+ Assert.assertEquals(menuItems.size, 2)
+ Assert.assertEquals(
+ menuItems[0].title,
+ testContext.getString(R.string.tab_tray_multiselect_menu_item_bookmark),
+ )
+ Assert.assertEquals(
+ menuItems[1].title,
+ testContext.getString(R.string.tab_tray_multiselect_menu_item_close),
+ )
+ }
+
+ @Test
+ fun `GIVEN normal mode is selected and no normal tabs are opened WHEN entering normal page THEN return 2 menu items`() {
+ val menuItems = initMenuItems(
+ mode = TabsTrayState.Mode.Normal,
+ )
+ Assert.assertEquals(menuItems.size, 2)
+ Assert.assertEquals(
+ menuItems[0].title,
+ testContext.getString(R.string.tab_tray_menu_tab_settings),
+ )
+ Assert.assertEquals(
+ menuItems[1].title,
+ testContext.getString(R.string.tab_tray_menu_recently_closed),
+ )
+ }
+
+ @Test
+ fun `GIVEN normal mode is selected and multiple normal tabs are opened WHEN entering normal page THEN return 5 menu items`() {
+ val menuItems = initMenuItems(
+ mode = TabsTrayState.Mode.Normal,
+ normalTabCount = 3,
+ )
+ Assert.assertEquals(menuItems.size, 5)
+ Assert.assertEquals(
+ menuItems[0].title,
+ testContext.getString(R.string.tabs_tray_select_tabs),
+ )
+ Assert.assertEquals(
+ menuItems[1].title,
+ testContext.getString(R.string.tab_tray_menu_item_share),
+ )
+ Assert.assertEquals(
+ menuItems[2].title,
+ testContext.getString(R.string.tab_tray_menu_tab_settings),
+ )
+ Assert.assertEquals(
+ menuItems[3].title,
+ testContext.getString(R.string.tab_tray_menu_recently_closed),
+ )
+ Assert.assertEquals(
+ menuItems[4].title,
+ testContext.getString(R.string.tab_tray_menu_item_close),
+ )
+ }
+
+ @Test
+ fun `GIVEN normal mode is selected and no private tabs are opened WHEN entering private page THEN return 2 menu items`() {
+ val menuItems = initMenuItems(
+ mode = TabsTrayState.Mode.Normal,
+ selectedPage = Page.PrivateTabs,
+ )
+ Assert.assertEquals(menuItems.size, 2)
+ Assert.assertEquals(
+ menuItems[0].title,
+ testContext.getString(R.string.tab_tray_menu_tab_settings),
+ )
+ Assert.assertEquals(
+ menuItems[1].title,
+ testContext.getString(R.string.tab_tray_menu_recently_closed),
+ )
+ }
+
+ @Test
+ fun `GIVEN normal mode is selected and multiple private tabs are opened WHEN entering private page THEN return 3 menu items`() {
+ val menuItems = initMenuItems(
+ mode = TabsTrayState.Mode.Normal,
+ selectedPage = Page.PrivateTabs,
+ privateTabCount = 6,
+ )
+ Assert.assertEquals(menuItems.size, 3)
+ Assert.assertEquals(
+ menuItems[0].title,
+ testContext.getString(R.string.tab_tray_menu_tab_settings),
+ )
+ Assert.assertEquals(
+ menuItems[1].title,
+ testContext.getString(R.string.tab_tray_menu_recently_closed),
+ )
+ Assert.assertEquals(
+ menuItems[2].title,
+ testContext.getString(R.string.tab_tray_menu_item_close),
+ )
+ }
+
+ @Test
+ fun `GIVEN normal mode is selected WHEN entering synced page THEN return 2 menu items`() {
+ val menuItems = initMenuItems(
+ mode = TabsTrayState.Mode.Normal,
+ selectedPage = Page.SyncedTabs,
+ )
+ Assert.assertEquals(menuItems.size, 2)
+ Assert.assertEquals(
+ menuItems[0].title,
+ testContext.getString(R.string.tab_tray_menu_account_settings),
+ )
+ Assert.assertEquals(
+ menuItems[1].title,
+ testContext.getString(R.string.tab_tray_menu_recently_closed),
+ )
+ }
+
+ private fun initMenuItems(
+ mode: TabsTrayState.Mode,
+ shouldShowInactiveButton: Boolean = false,
+ selectedPage: Page = Page.NormalTabs,
+ normalTabCount: Int = 0,
+ privateTabCount: Int = 0,
+ ): List<MenuItem> =
+ mode.getMenuItems(
+ resources = testContext.resources,
+ shouldShowInactiveButton = shouldShowInactiveButton,
+ selectedPage = selectedPage,
+ normalTabCount = normalTabCount,
+ privateTabCount = privateTabCount,
+ onBookmarkSelectedTabsClick = {},
+ onCloseSelectedTabsClick = {},
+ onMakeSelectedTabsInactive = {},
+ onTabSettingsClick = {},
+ onRecentlyClosedClick = {},
+ onEnterMultiselectModeClick = {},
+ onShareAllTabsClick = {},
+ onDeleteAllTabsClick = {},
+ onAccountSettingsClick = {},
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayStoreReducerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayStoreReducerTest.kt
new file mode 100644
index 0000000000..34e5c34bb0
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayStoreReducerTest.kt
@@ -0,0 +1,75 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray
+
+import mozilla.components.browser.state.state.createTab
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.mozilla.fenix.tabstray.syncedtabs.getFakeSyncedTabList
+
+class TabsTrayStoreReducerTest {
+
+ @Test
+ fun `WHEN UpdateInactiveTabs THEN inactive tabs are added`() {
+ val inactiveTabs = listOf(
+ createTab("https://mozilla.org"),
+ )
+ val initialState = TabsTrayState()
+ val expectedState = initialState.copy(inactiveTabs = inactiveTabs)
+
+ val resultState = TabsTrayReducer.reduce(
+ initialState,
+ TabsTrayAction.UpdateInactiveTabs(inactiveTabs),
+ )
+
+ assertEquals(expectedState, resultState)
+ }
+
+ @Test
+ fun `WHEN UpdateNormalTabs THEN normal tabs are added`() {
+ val normalTabs = listOf(
+ createTab("https://mozilla.org"),
+ )
+ val initialState = TabsTrayState()
+ val expectedState = initialState.copy(normalTabs = normalTabs)
+
+ val resultState = TabsTrayReducer.reduce(
+ initialState,
+ TabsTrayAction.UpdateNormalTabs(normalTabs),
+ )
+
+ assertEquals(expectedState, resultState)
+ }
+
+ @Test
+ fun `WHEN UpdatePrivateTabs THEN private tabs are added`() {
+ val privateTabs = listOf(
+ createTab("https://mozilla.org", private = true),
+ )
+ val initialState = TabsTrayState()
+ val expectedState = initialState.copy(privateTabs = privateTabs)
+
+ val resultState = TabsTrayReducer.reduce(
+ initialState,
+ TabsTrayAction.UpdatePrivateTabs(privateTabs),
+ )
+
+ assertEquals(expectedState, resultState)
+ }
+
+ @Test
+ fun `WHEN UpdateSyncedTabs THEN synced tabs are added`() {
+ val syncedTabs = getFakeSyncedTabList()
+ val initialState = TabsTrayState()
+ val expectedState = initialState.copy(syncedTabs = syncedTabs)
+
+ val resultState = TabsTrayReducer.reduce(
+ initialState,
+ TabsTrayAction.UpdateSyncedTabs(syncedTabs),
+ )
+
+ assertEquals(expectedState, resultState)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayStoreTest.kt
new file mode 100644
index 0000000000..a6b2b24808
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayStoreTest.kt
@@ -0,0 +1,140 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray
+
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class TabsTrayStoreTest {
+
+ @Test
+ fun `WHEN entering select mode THEN selected tabs are empty`() {
+ val store = TabsTrayStore()
+
+ store.dispatch(TabsTrayAction.EnterSelectMode)
+
+ store.waitUntilIdle()
+
+ assertTrue(store.state.mode.selectedTabs.isEmpty())
+ assertTrue(store.state.mode is TabsTrayState.Mode.Select)
+
+ store.dispatch(TabsTrayAction.AddSelectTab(createTab(url = "url")))
+
+ store.dispatch(TabsTrayAction.ExitSelectMode)
+ store.dispatch(TabsTrayAction.EnterSelectMode)
+
+ store.waitUntilIdle()
+
+ assertTrue(store.state.mode.selectedTabs.isEmpty())
+ assertTrue(store.state.mode is TabsTrayState.Mode.Select)
+ }
+
+ @Test
+ fun `WHEN exiting select mode THEN the mode in the state updates`() {
+ val store = TabsTrayStore()
+
+ store.dispatch(TabsTrayAction.EnterSelectMode)
+
+ store.waitUntilIdle()
+
+ assertTrue(store.state.mode is TabsTrayState.Mode.Select)
+
+ store.dispatch(TabsTrayAction.ExitSelectMode)
+
+ store.waitUntilIdle()
+
+ assertTrue(store.state.mode is TabsTrayState.Mode.Normal)
+ }
+
+ @Test
+ fun `WHEN adding a tab to selection THEN it is added to the selectedTabs`() {
+ val store = TabsTrayStore()
+
+ store.dispatch(TabsTrayAction.AddSelectTab(createTab(url = "url", id = "tab1")))
+
+ store.waitUntilIdle()
+
+ assertEquals("tab1", store.state.mode.selectedTabs.take(1).first().id)
+ }
+
+ @Test
+ fun `WHEN removing a tab THEN it is removed from the selectedTabs`() {
+ val store = TabsTrayStore()
+ val tabForRemoval = createTab(url = "url", id = "tab1")
+
+ store.dispatch(TabsTrayAction.AddSelectTab(tabForRemoval))
+ store.dispatch(TabsTrayAction.AddSelectTab(createTab(url = "url", id = "tab2")))
+
+ store.waitUntilIdle()
+
+ assertEquals(2, store.state.mode.selectedTabs.size)
+
+ store.dispatch(TabsTrayAction.RemoveSelectTab(tabForRemoval))
+
+ store.waitUntilIdle()
+
+ assertEquals(1, store.state.mode.selectedTabs.size)
+ assertEquals("tab2", store.state.mode.selectedTabs.take(1).first().id)
+ }
+
+ @Test
+ fun `WHEN store is initialized THEN the default page selected in normal tabs`() {
+ val store = TabsTrayStore()
+
+ assertEquals(Page.NormalTabs, store.state.selectedPage)
+ }
+
+ @Test
+ fun `WHEN page changes THEN the selectedPage is updated`() {
+ val store = TabsTrayStore()
+
+ assertEquals(Page.NormalTabs, store.state.selectedPage)
+
+ store.dispatch(TabsTrayAction.PageSelected(Page.SyncedTabs))
+
+ store.waitUntilIdle()
+
+ assertEquals(Page.SyncedTabs, store.state.selectedPage)
+ }
+
+ @Test
+ fun `WHEN position is converted to page THEN page is correct`() {
+ assert(Page.positionToPage(0) == Page.NormalTabs)
+ assert(Page.positionToPage(1) == Page.PrivateTabs)
+ assert(Page.positionToPage(2) == Page.SyncedTabs)
+ assert(Page.positionToPage(3) == Page.SyncedTabs)
+ assert(Page.positionToPage(-1) == Page.SyncedTabs)
+ }
+
+ @Test
+ fun `WHEN sync now action is triggered THEN update the sync now boolean`() {
+ val store = TabsTrayStore()
+
+ assertFalse(store.state.syncing)
+
+ store.dispatch(TabsTrayAction.SyncNow)
+
+ store.waitUntilIdle()
+
+ assertTrue(store.state.syncing)
+ }
+
+ @Test
+ fun `WHEN sync is complete THEN the syncing boolean is updated`() {
+ val store = TabsTrayStore(initialState = TabsTrayState(syncing = true))
+
+ assertTrue(store.state.syncing)
+
+ store.dispatch(TabsTrayAction.SyncCompleted)
+
+ store.waitUntilIdle()
+
+ assertFalse(store.state.syncing)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/AbstractBrowserTabViewHolderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/AbstractBrowserTabViewHolderTest.kt
new file mode 100644
index 0000000000..5fab000b18
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/AbstractBrowserTabViewHolderTest.kt
@@ -0,0 +1,190 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray.browser
+
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.ImageButton
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.MediaSessionState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.base.images.ImageLoader
+import mozilla.components.concept.engine.mediasession.MediaSession
+import mozilla.components.lib.publicsuffixlist.PublicSuffixList
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.telemetry.glean.testing.GleanTestRule
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.Tab
+import org.mozilla.fenix.R
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.selection.SelectionHolder
+import org.mozilla.fenix.tabstray.TabsTrayInteractor
+import org.mozilla.fenix.tabstray.TabsTrayStore
+
+@RunWith(FenixRobolectricTestRunner::class)
+class AbstractBrowserTabViewHolderTest {
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ val store = TabsTrayStore()
+ val browserStore = BrowserStore()
+ val interactor = mockk<TabsTrayInteractor>(relaxed = true)
+
+ @Test
+ fun `WHEN itemView is clicked THEN interactor invokes open`() {
+ every { testContext.components.publicSuffixList } returns PublicSuffixList(testContext)
+ val view = LayoutInflater.from(testContext).inflate(R.layout.tab_tray_item, null)
+ val holder = TestTabTrayViewHolder(
+ view,
+ mockk(relaxed = true),
+ store,
+ null,
+ browserStore,
+ interactor,
+ )
+
+ holder.bind(createTab(url = "url"), false, mockk(), mockk())
+
+ holder.itemView.performClick()
+
+ verify { interactor.onTabSelected(any(), holder.featureName) }
+ }
+
+ @Test
+ fun `WHEN itemView is clicked with a selection holder THEN the select holder is invoked`() {
+ every { testContext.components.publicSuffixList } returns PublicSuffixList(testContext)
+ val view = LayoutInflater.from(testContext).inflate(R.layout.tab_tray_item, null)
+ val selectionHolder = TestSelectionHolder(emptySet())
+ val holder = TestTabTrayViewHolder(
+ view,
+ mockk(relaxed = true),
+ store,
+ selectionHolder,
+ browserStore,
+ interactor,
+ )
+
+ val tab = createTab(url = "url")
+ holder.bind(tab, false, mockk(), mockk())
+
+ holder.itemView.performClick()
+
+ verify { interactor.onTabSelected(tab, holder.featureName) }
+ }
+
+ @Test
+ fun `WHEN the current media state is paused AND playPause button is clicked THEN the media is played AND the right metric is recorded`() {
+ every { testContext.components.publicSuffixList } returns PublicSuffixList(testContext)
+ val view = LayoutInflater.from(testContext).inflate(R.layout.tab_tray_item, null)
+ val mediaSessionController = mockk<MediaSession.Controller>(relaxed = true)
+ val mediaTab = createTab(
+ url = "url",
+ mediaSessionState = MediaSessionState(
+ mediaSessionController,
+ playbackState = MediaSession.PlaybackState.PAUSED,
+ ),
+ )
+ val mediaBrowserStore = BrowserStore(
+ initialState =
+ BrowserState(listOf(mediaTab)),
+ )
+ val holder = TestTabTrayViewHolder(
+ view,
+ mockk(relaxed = true),
+ store,
+ TestSelectionHolder(emptySet()),
+ mediaBrowserStore,
+ interactor,
+ )
+ assertNull(Tab.mediaPlay.testGetValue())
+
+ holder.bind(mediaTab, false, mockk(), mockk())
+
+ holder.itemView.findViewById<ImageButton>(R.id.play_pause_button).performClick()
+
+ assertNotNull(Tab.mediaPlay.testGetValue())
+ assertEquals(1, Tab.mediaPlay.testGetValue()!!.size)
+ assertNull(Tab.mediaPlay.testGetValue()!!.single().extra)
+
+ verify { mediaSessionController.play() }
+ }
+
+ @Test
+ fun `WHEN the current media state is playing AND playPause button is clicked THEN the media is paused AND the right metric is recorded`() {
+ every { testContext.components.publicSuffixList } returns PublicSuffixList(testContext)
+ val view = LayoutInflater.from(testContext).inflate(R.layout.tab_tray_item, null)
+ val mediaSessionController = mockk<MediaSession.Controller>(relaxed = true)
+ val mediaTab = createTab(
+ url = "url",
+ mediaSessionState = MediaSessionState(
+ mediaSessionController,
+ playbackState = MediaSession.PlaybackState.PLAYING,
+ ),
+ )
+ val mediaBrowserStore = BrowserStore(
+ initialState =
+ BrowserState(listOf(mediaTab)),
+ )
+ val holder = TestTabTrayViewHolder(
+ view,
+ mockk(relaxed = true),
+ store,
+ TestSelectionHolder(emptySet()),
+ mediaBrowserStore,
+ interactor,
+ )
+ assertNull(Tab.mediaPause.testGetValue())
+
+ holder.bind(mediaTab, false, mockk(), mockk())
+
+ holder.itemView.findViewById<ImageButton>(R.id.play_pause_button).performClick()
+
+ assertNotNull(Tab.mediaPause.testGetValue())
+ assertEquals(1, Tab.mediaPause.testGetValue()!!.size)
+ assertNull(Tab.mediaPause.testGetValue()!!.single().extra)
+
+ verify { mediaSessionController.pause() }
+ }
+
+ class TestTabTrayViewHolder(
+ itemView: View,
+ imageLoader: ImageLoader,
+ trayStore: TabsTrayStore,
+ selectionHolder: SelectionHolder<TabSessionState>?,
+ store: BrowserStore,
+ override val interactor: TabsTrayInteractor,
+ featureName: String = "Test",
+ ) : AbstractBrowserTabViewHolder(itemView, imageLoader, trayStore, selectionHolder, featureName, store) {
+ override val thumbnailSize: Int
+ get() = 30
+
+ override fun updateSelectedTabIndicator(showAsSelected: Boolean) {
+ // do nothing
+ }
+ }
+
+ class TestSelectionHolder(
+ private val testItems: Set<TabSessionState>,
+ ) : SelectionHolder<TabSessionState> {
+ override val selectedItems: Set<TabSessionState>
+ get() {
+ invoked = true
+ return testItems
+ }
+
+ var invoked = false
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/AbstractBrowserTrayListTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/AbstractBrowserTrayListTest.kt
new file mode 100644
index 0000000000..ab443dc01f
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/AbstractBrowserTrayListTest.kt
@@ -0,0 +1,34 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray.browser
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.tabstray.TabsTrayStore
+
+@RunWith(FenixRobolectricTestRunner::class)
+class AbstractBrowserTrayListTest {
+
+ @Test
+ fun `WHEN recyclerview detaches from window THEN notify adapter`() {
+ every { testContext.components.core.store } returns BrowserStore()
+ val trayList = PrivateBrowserTrayList(testContext)
+ val adapter = mockk<BrowserTabsAdapter>(relaxed = true)
+
+ trayList.adapter = adapter
+ trayList.tabsTrayStore = TabsTrayStore()
+
+ trayList.onDetachedFromWindow()
+
+ verify { adapter.onDetachedFromRecyclerView(trayList) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/BrowserTabsAdapterTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/BrowserTabsAdapterTest.kt
new file mode 100644
index 0000000000..2458b97322
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/BrowserTabsAdapterTest.kt
@@ -0,0 +1,99 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray.browser
+
+import android.view.LayoutInflater
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM
+import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_HIGHLIGHT_SELECTED_ITEM
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.databinding.TabTrayItemBinding
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.selection.SelectionHolder
+import org.mozilla.fenix.tabstray.TabsTrayInteractor
+import org.mozilla.fenix.tabstray.TabsTrayStore
+
+@RunWith(FenixRobolectricTestRunner::class)
+class BrowserTabsAdapterTest {
+
+ private val context = testContext
+ private val interactor = mockk<TabsTrayInteractor>(relaxed = true)
+ private val store = TabsTrayStore()
+
+ @Test
+ fun `WHEN bind with payloads is called THEN update the holder`() {
+ every { testContext.components.core.thumbnailStorage } returns mockk()
+ val adapter = BrowserTabsAdapter(context, interactor, store, "Test", mockk())
+ val holder = mockk<AbstractBrowserTabViewHolder>(relaxed = true)
+
+ adapter.updateTabs(
+ listOf(
+ createTab(url = "url", id = "tab1"),
+ ),
+ null,
+ selectedTabId = "tab1",
+ )
+
+ adapter.onBindViewHolder(holder, 0, listOf(PAYLOAD_HIGHLIGHT_SELECTED_ITEM))
+
+ verify { holder.updateSelectedTabIndicator(true) }
+
+ adapter.onBindViewHolder(holder, 0, listOf(PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM))
+
+ verify { holder.updateSelectedTabIndicator(false) }
+ }
+
+ @Test
+ fun `WHEN the selection holder is set THEN update the selected tab`() {
+ every { testContext.components.core.thumbnailStorage } returns mockk()
+ every { testContext.components.core.store } returns BrowserStore()
+ every { testContext.components.analytics } returns mockk(relaxed = true)
+ every { testContext.components.settings } returns mockk(relaxed = true)
+ val adapter = BrowserTabsAdapter(context, interactor, store, "Test", mockk())
+ val binding = TabTrayItemBinding.inflate(LayoutInflater.from(testContext))
+ val holder = spyk(
+ BrowserTabViewHolder.ListViewHolder(
+ imageLoader = mockk(),
+ interactor = interactor,
+ store = store,
+ selectionHolder = null,
+ itemView = binding.root,
+ featureName = "Test",
+ ),
+ )
+ val tab = createTab(url = "url", id = "tab1")
+
+ every { holder.tab }.answers { tab }
+
+ testSelectionHolder.internalState.add(tab)
+ adapter.selectionHolder = testSelectionHolder
+
+ adapter.updateTabs(
+ listOf(tab),
+ null,
+ selectedTabId = "tab1",
+ )
+
+ adapter.onBindViewHolder(holder, 0, listOf(PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM))
+
+ verify { holder.showTabIsMultiSelectEnabled(any(), true) }
+ }
+
+ private val testSelectionHolder = object : SelectionHolder<TabSessionState> {
+ override val selectedItems: Set<TabSessionState>
+ get() = internalState
+
+ val internalState = mutableSetOf<TabSessionState>()
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/NormalTabsBindingTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/NormalTabsBindingTest.kt
new file mode 100644
index 0000000000..c49614d995
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/NormalTabsBindingTest.kt
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray.browser
+
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.browser.tabstray.TabsTray
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.tabstray.TabsTrayAction
+import org.mozilla.fenix.tabstray.TabsTrayStore
+
+class NormalTabsBindingTest {
+ val store = TabsTrayStore()
+ val browserStore = BrowserStore(BrowserState(tabs = listOf(createTab("", id = "1")), selectedTabId = "1"))
+ val tray: TabsTray = mockk(relaxed = true)
+ val binding = NormalTabsBinding(store, browserStore, tray)
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @After
+ fun teardown() {
+ binding.stop()
+ }
+
+ @Test
+ fun `WHEN the store is updated THEN notify the tabs tray`() {
+ val slotTabs = slot<List<TabSessionState>>()
+ val expectedTabs = listOf(createTab("https://mozilla.org"))
+
+ assertTrue(store.state.normalTabs.isEmpty())
+
+ store.dispatch(TabsTrayAction.UpdateNormalTabs(expectedTabs)).joinBlocking()
+
+ binding.start()
+
+ assertTrue(store.state.normalTabs.isNotEmpty())
+
+ verify { tray.updateTabs(capture(slotTabs), null, "1") }
+ assertEquals(expectedTabs, slotTabs.captured)
+ }
+
+ @Test
+ fun `WHEN non-inactive tabs are updated THEN do not notify the tabs tray`() {
+ assertTrue(store.state.normalTabs.isEmpty())
+
+ store.dispatch(TabsTrayAction.UpdatePrivateTabs(listOf(createTab("https://mozilla.org")))).joinBlocking()
+
+ binding.start()
+
+ assertTrue(store.state.normalTabs.isEmpty())
+
+ verify { tray.updateTabs(emptyList(), null, "1") }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/PrivateTabsBindingTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/PrivateTabsBindingTest.kt
new file mode 100644
index 0000000000..0dc2fbce00
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/PrivateTabsBindingTest.kt
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray.browser
+
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.browser.tabstray.TabsTray
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.tabstray.TabsTrayAction
+import org.mozilla.fenix.tabstray.TabsTrayStore
+
+class PrivateTabsBindingTest {
+ val store = TabsTrayStore()
+ val browserStore = BrowserStore(BrowserState(tabs = listOf(createTab("", id = "1")), selectedTabId = "1"))
+ val tray: TabsTray = mockk(relaxed = true)
+ val binding = PrivateTabsBinding(store, browserStore, tray)
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @After
+ fun teardown() {
+ binding.stop()
+ }
+
+ @Test
+ fun `WHEN the store is updated THEN notify the tabs tray`() {
+ val slotTabs = slot<List<TabSessionState>>()
+ val expectedTabs = listOf(createTab("https://mozilla.org", private = true))
+
+ assertTrue(store.state.privateTabs.isEmpty())
+
+ store.dispatch(TabsTrayAction.UpdatePrivateTabs(expectedTabs)).joinBlocking()
+
+ binding.start()
+
+ assertTrue(store.state.privateTabs.isNotEmpty())
+
+ verify { tray.updateTabs(capture(slotTabs), null, "1") }
+ assertEquals(expectedTabs, slotTabs.captured)
+ }
+
+ @Test
+ fun `WHEN non-inactive tabs are updated THEN do not notify the tabs tray`() {
+ assertTrue(store.state.privateTabs.isEmpty())
+
+ store.dispatch(TabsTrayAction.UpdateInactiveTabs(listOf(createTab("https://mozilla.org", private = true))))
+ .joinBlocking()
+
+ binding.start()
+
+ assertTrue(store.state.privateTabs.isEmpty())
+
+ verify { tray.updateTabs(emptyList(), null, "1") }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/SelectedItemAdapterBindingTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/SelectedItemAdapterBindingTest.kt
new file mode 100644
index 0000000000..36bf3685a7
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/SelectedItemAdapterBindingTest.kt
@@ -0,0 +1,57 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray.browser
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM
+import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_HIGHLIGHT_SELECTED_ITEM
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.tabstray.TabsTrayAction
+import org.mozilla.fenix.tabstray.TabsTrayStore
+
+class SelectedItemAdapterBindingTest {
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ private val adapter = mockk<BrowserTabsAdapter>(relaxed = true)
+
+ @Before
+ fun setup() {
+ every { adapter.itemCount }.answers { 1 }
+ }
+
+ @Test
+ fun `WHEN mode changes THEN notify the adapter`() {
+ val store = TabsTrayStore()
+ val binding = SelectedItemAdapterBinding(store, adapter)
+
+ binding.start()
+
+ store.dispatch(TabsTrayAction.EnterSelectMode)
+
+ store.waitUntilIdle()
+
+ verify {
+ adapter.notifyItemRangeChanged(eq(0), eq(1), eq(PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM))
+ }
+
+ store.dispatch(TabsTrayAction.ExitSelectMode)
+
+ store.waitUntilIdle()
+
+ verify {
+ adapter.notifyItemRangeChanged(eq(0), eq(1), eq(PAYLOAD_HIGHLIGHT_SELECTED_ITEM))
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/SelectionMenuIntegrationTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/SelectionMenuIntegrationTest.kt
new file mode 100644
index 0000000000..7ebf983c9c
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/SelectionMenuIntegrationTest.kt
@@ -0,0 +1,33 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray.browser
+
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Test
+import org.mozilla.fenix.tabstray.TabsTrayInteractor
+
+class SelectionMenuIntegrationTest {
+
+ private val interactor = mockk<TabsTrayInteractor>(relaxed = true)
+
+ @Test
+ fun `WHEN bookmark item is clicked THEN invoke interactor and close tray`() {
+ val menu = SelectionMenuIntegration(mockk(), interactor)
+
+ menu.handleMenuClicked(SelectionMenu.Item.BookmarkTabs)
+
+ verify { interactor.onBookmarkSelectedTabsClicked() }
+ }
+
+ @Test
+ fun `WHEN delete tabs item is clicked THEN invoke interactor and close tray`() {
+ val menu = SelectionMenuIntegration(mockk(), interactor)
+
+ menu.handleMenuClicked(SelectionMenu.Item.DeleteTabs)
+
+ verify { interactor.onDeleteSelectedTabsClicked() }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/SwipeToDeleteBindingTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/SwipeToDeleteBindingTest.kt
new file mode 100644
index 0000000000..a94d6e9460
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/SwipeToDeleteBindingTest.kt
@@ -0,0 +1,47 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray.browser
+
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.tabstray.TabsTrayAction
+import org.mozilla.fenix.tabstray.TabsTrayState
+import org.mozilla.fenix.tabstray.TabsTrayStore
+
+class SwipeToDeleteBindingTest {
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Test
+ fun `WHEN started THEN update the swipeable state`() {
+ val store = TabsTrayStore(TabsTrayState(mode = TabsTrayState.Mode.Select(emptySet())))
+ val binding = SwipeToDeleteBinding(store)
+
+ binding.start()
+
+ assertFalse(binding.isSwipeable)
+
+ store.dispatch(TabsTrayAction.ExitSelectMode)
+
+ store.waitUntilIdle()
+
+ assertTrue(binding.isSwipeable)
+ }
+
+ @Test
+ fun `default state of binding is false`() {
+ val binding = SwipeToDeleteBinding(mockk())
+
+ assertFalse(binding.isSwipeable)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/TabSorterTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/TabSorterTest.kt
new file mode 100644
index 0000000000..77f00990b0
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/TabSorterTest.kt
@@ -0,0 +1,124 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray.browser
+
+import io.mockk.every
+import io.mockk.mockk
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.tabstray.TabsTrayStore
+import org.mozilla.fenix.utils.Settings
+
+class TabSorterTest {
+ private val settings: Settings = mockk()
+ private var inactiveTimestamp = 0L
+ private val tabsTrayStore = TabsTrayStore()
+
+ @Before
+ fun setUp() {
+ every { settings.inactiveTabsAreEnabled }.answers { true }
+ }
+
+ @Test
+ fun `WHEN updated with one normal tab THEN adapter have only one normal tab`() {
+ val tabSorter = TabSorter(settings, tabsTrayStore)
+
+ tabSorter.updateTabs(
+ listOf(
+ createTab(url = "url", id = "tab1", lastAccess = System.currentTimeMillis()),
+ ),
+ null,
+ selectedTabId = "tab1",
+ )
+
+ tabsTrayStore.waitUntilIdle()
+
+ assertEquals(tabsTrayStore.state.inactiveTabs.size, 0)
+ assertEquals(tabsTrayStore.state.normalTabs.size, 1)
+ }
+
+ @Test
+ fun `WHEN updated with one normal tab and one inactive tab THEN adapter have normal tab and inactive tab`() {
+ val tabSorter = TabSorter(settings, tabsTrayStore)
+
+ tabSorter.updateTabs(
+ listOf(
+ createTab(url = "url", id = "tab1", lastAccess = System.currentTimeMillis()),
+ createTab(url = "url", id = "tab2", lastAccess = inactiveTimestamp, createdAt = inactiveTimestamp),
+ ),
+ tabPartition = null,
+ selectedTabId = "tab1",
+ )
+
+ tabsTrayStore.waitUntilIdle()
+
+ assertEquals(tabsTrayStore.state.inactiveTabs.size, 1)
+ assertEquals(tabsTrayStore.state.normalTabs.size, 1)
+ }
+
+ @Test
+ fun `WHEN inactive tabs is off THEN adapter have no inactive tab`() {
+ every { settings.inactiveTabsAreEnabled }.answers { false }
+ val tabSorter = TabSorter(settings, tabsTrayStore)
+
+ tabSorter.updateTabs(
+ listOf(
+ createTab(url = "url", id = "tab1", lastAccess = System.currentTimeMillis()),
+ createTab(
+ url = "url",
+ id = "tab2",
+ lastAccess = inactiveTimestamp,
+ createdAt = inactiveTimestamp,
+ ),
+ ),
+ tabPartition = null,
+ selectedTabId = "tab1",
+ )
+
+ tabsTrayStore.waitUntilIdle()
+
+ assertEquals(tabsTrayStore.state.inactiveTabs.size, 0)
+ assertEquals(tabsTrayStore.state.normalTabs.size, 2)
+ }
+
+ @Test
+ fun `WHEN inactive tabs are disabled THEN adapter have only normal tabs`() {
+ every { settings.inactiveTabsAreEnabled }.answers { false }
+ val tabSorter = TabSorter(settings, tabsTrayStore)
+
+ tabSorter.updateTabs(
+ listOf(
+ createTab(url = "url", id = "tab1", lastAccess = System.currentTimeMillis()),
+ createTab(
+ url = "url",
+ id = "tab2",
+ lastAccess = inactiveTimestamp,
+ ),
+ createTab(
+ url = "url",
+ id = "tab3",
+ lastAccess = System.currentTimeMillis(),
+ searchTerms = "mozilla",
+ ),
+ createTab(
+ url = "url",
+ id = "tab4",
+ lastAccess = System.currentTimeMillis(),
+ searchTerms = "mozilla",
+ ),
+ ),
+ tabPartition = null,
+ selectedTabId = "tab1",
+ )
+
+ tabsTrayStore.waitUntilIdle()
+
+ assertEquals(tabsTrayStore.state.inactiveTabs.size, 0)
+ assertEquals(tabsTrayStore.state.normalTabs.size, 4)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/TabsTouchHelperTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/TabsTouchHelperTest.kt
new file mode 100644
index 0000000000..9dd16fad73
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/browser/TabsTouchHelperTest.kt
@@ -0,0 +1,65 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray.browser
+
+import androidx.compose.ui.platform.ComposeView
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_IDLE
+import androidx.recyclerview.widget.ItemTouchHelper.Callback.makeMovementFlags
+import androidx.recyclerview.widget.RecyclerView
+import io.mockk.mockk
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.tabstray.viewholders.SyncedTabsPageViewHolder
+
+@RunWith(FenixRobolectricTestRunner::class)
+class TabsTouchHelperTest {
+
+ private val featureName = object : FeatureNameHolder {
+ override val featureName: String
+ get() = "featureName"
+ }
+
+ @Test
+ fun `movement flags remain unchanged if onSwipeToDelete is true`() {
+ val recyclerView = RecyclerView(testContext)
+ val layout = ComposeView(testContext)
+ val viewHolder = SyncedTabsPageViewHolder(layout, mockk(), mockk())
+ val callback = TouchCallback(mockk(), { true }, { false }, featureName)
+
+ assertEquals(0, callback.getDragDirs(recyclerView, viewHolder))
+ assertEquals(
+ ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT,
+ callback.getSwipeDirs(recyclerView, viewHolder),
+ )
+
+ val actual = callback.getMovementFlags(recyclerView, viewHolder)
+ val expected = makeMovementFlags(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT)
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `movement flags remain unchanged if onSwipeToDelete is false`() {
+ val recyclerView = RecyclerView(testContext)
+ val layout = ComposeView(testContext)
+ val viewHolder = SyncedTabsPageViewHolder(layout, mockk(), mockk())
+ val callback = TouchCallback(mockk(), { false }, { false }, featureName)
+
+ assertEquals(0, callback.getDragDirs(recyclerView, viewHolder))
+ assertEquals(
+ ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT,
+ callback.getSwipeDirs(recyclerView, viewHolder),
+ )
+
+ val actual = callback.getMovementFlags(recyclerView, viewHolder)
+ val expected = ItemTouchHelper.Callback.makeFlag(ACTION_STATE_IDLE, 0)
+
+ assertEquals(expected, actual)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/ext/BrowserStoreKtTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/ext/BrowserStoreKtTest.kt
new file mode 100644
index 0000000000..e897a80228
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/ext/BrowserStoreKtTest.kt
@@ -0,0 +1,59 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray.ext
+
+import io.mockk.mockk
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class BrowserStoreKtTest {
+
+ @Test
+ fun `WHEN session is found THEN return it`() {
+ val store = BrowserStore(
+ initialState = BrowserState(
+ listOf(
+ TabSessionState(id = "tab1", mockk(), lastAccess = 3),
+ TabSessionState(id = "tab2", mockk(), lastAccess = 5),
+ ),
+ ),
+ )
+
+ val tabs = listOf(
+ createTab(url = "url", id = "tab1"),
+ createTab(url = "url", id = "tab2"),
+ )
+
+ val result = store.getTabSessionState(tabs)
+
+ assertEquals(3, result[0].lastAccess)
+ assertEquals(5, result[1].lastAccess)
+ }
+
+ @Test
+ fun `WHEN session is not found THEN ignore it`() {
+ val store = BrowserStore(
+ initialState = BrowserState(
+ listOf(
+ TabSessionState(id = "tab2", mockk(), lastAccess = 5),
+ ),
+ ),
+ )
+
+ val tabs = listOf(
+ createTab(url = "url", id = "tab1"),
+ createTab(url = "url", id = "tab2"),
+ )
+
+ val result = store.getTabSessionState(tabs)
+
+ assertEquals(5, result[0].lastAccess)
+ assertEquals(1, result.size)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/ext/ContextKtTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/ext/ContextKtTest.kt
new file mode 100644
index 0000000000..3e6ad82a4b
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/ext/ContextKtTest.kt
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray.ext
+
+import android.content.Context
+import android.content.res.Resources
+import android.util.DisplayMetrics
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.spyk
+import io.mockk.unmockkStatic
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ContextKtTest {
+
+ @Test
+ fun `WHEN screen density is very low THEN numberOfGridColumns will still be a minimum of 2`() {
+ mockkStatic("org.mozilla.fenix.tabstray.ext.ContextKt")
+
+ val context = mockk<Context>()
+ val resources = mockk<Resources>()
+ val displayMetrics = spyk<DisplayMetrics> {
+ widthPixels = 1
+ density = 1f
+ }
+ every { context.resources } returns resources
+ every { resources.displayMetrics } returns displayMetrics
+
+ val result = context.numberOfGridColumns
+
+ assertEquals(2, result)
+
+ unmockkStatic("org.mozilla.fenix.tabstray.ext.ContextKt")
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/ext/FenixSnackbarKtTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/ext/FenixSnackbarKtTest.kt
new file mode 100644
index 0000000000..a8ae568af1
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/ext/FenixSnackbarKtTest.kt
@@ -0,0 +1,130 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray.ext
+
+import android.content.Context
+import android.view.View
+import android.widget.FrameLayout
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.verifyOrder
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.components.FenixSnackbar
+import org.mozilla.fenix.components.FenixSnackbarBehavior
+import org.mozilla.fenix.components.toolbar.ToolbarPosition
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.helpers.MockkRetryTestRule
+import org.mozilla.fenix.tabstray.TabsTrayFragment.Companion.ELEVATION
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class FenixSnackbarKtTest {
+
+ @get:Rule
+ val mockkRule = MockkRetryTestRule()
+
+ @Test
+ fun `WHEN collectionMessage is called with different parameters THEN correct text will be set`() {
+ val mockContext: Context = mockk {
+ every { getString(R.string.create_collection_tabs_saved_new_collection) }
+ .answers { "test1" }
+ every { getString(R.string.create_collection_tabs_saved) }
+ .answers { "test2" }
+ every { getString(R.string.create_collection_tab_saved) }
+ .answers { "test3" }
+ }
+ val snackbar: FenixSnackbar = mockk {
+ every { context }.answers { mockContext }
+ }
+ every { snackbar.setText(any()) }.answers { snackbar }
+
+ snackbar.collectionMessage(1, true)
+ snackbar.collectionMessage(2, false)
+ snackbar.collectionMessage(1, false)
+
+ verifyOrder {
+ snackbar.setText("test1")
+ snackbar.setText("test2")
+ snackbar.setText("test3")
+ }
+ }
+
+ @Test
+ fun `WHEN bookmarkMessage is called with different parameters THEN correct text will be set`() {
+ val mockContext: Context = mockk {
+ every { getString(R.string.bookmark_saved_snackbar) }
+ .answers { "test1" }
+ every { getString(R.string.snackbar_message_bookmarks_saved) }
+ .answers { "test2" }
+ }
+ val snackbar: FenixSnackbar = mockk {
+ every { context }.answers { mockContext }
+ }
+ every { snackbar.setText(any()) }.answers { snackbar }
+
+ snackbar.bookmarkMessage(1)
+ snackbar.bookmarkMessage(2)
+
+ verifyOrder {
+ snackbar.setText("test1")
+ snackbar.setText("test2")
+ }
+ }
+
+ @Test
+ fun `WHEN anchorWithAction is called THEN correct text will be set`() {
+ val mockContext: Context = mockk {
+ every { getString(R.string.create_collection_view) }
+ .answers { "test1" }
+ }
+ val anchor: View = mockk(relaxed = true)
+ val view: View = mockk(relaxed = true)
+ val snackbar: FenixSnackbar = mockk {
+ every { context }.answers { mockContext }
+ }
+
+ every { snackbar.setAnchorView(anchor) }.answers { snackbar }
+ every { snackbar.view }.answers { view }
+ every { snackbar.setAction(any(), any()) }.answers { snackbar }
+ every { snackbar.anchorView }.answers { anchor }
+
+ snackbar.anchorWithAction(anchor, {})
+
+ verifyOrder {
+ snackbar.anchorView = anchor
+ view.elevation = ELEVATION
+ snackbar.setAction("test1", any())
+ }
+ }
+
+ @Test
+ fun `GIVEN the snackbar is a child of dynamic container WHEN it is shown THEN enable the dynamic behavior`() {
+ val container = FrameLayout(testContext).apply {
+ id = R.id.dynamicSnackbarContainer
+ layoutParams = CoordinatorLayout.LayoutParams(0, 0)
+ }
+ val settings: Settings = mockk(relaxed = true) {
+ every { toolbarPosition } returns ToolbarPosition.BOTTOM
+ }
+ mockkStatic("org.mozilla.fenix.ext.ContextKt") {
+ every { any<Context>().settings() } returns settings
+
+ FenixSnackbar.make(view = container)
+
+ val behavior = (container.layoutParams as? CoordinatorLayout.LayoutParams)?.behavior
+ assertTrue(behavior is FenixSnackbarBehavior)
+ assertEquals(ToolbarPosition.BOTTOM, (behavior as? FenixSnackbarBehavior)?.toolbarPosition)
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/ext/LongKtTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/ext/LongKtTest.kt
new file mode 100644
index 0000000000..25cc138880
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/ext/LongKtTest.kt
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray.ext
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class LongKtTest {
+
+ @Test
+ fun `WHEN value is null THEN default is returned`() {
+ val value: Long? = null
+
+ assertEquals(value.orDefault(), -1L)
+ }
+
+ @Test
+ fun `WHEN value is not null THEN value is returned`() {
+ val value: Long? = 100L
+
+ assertEquals(value.orDefault(), 100L)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/ext/TabSessionStateKtTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/ext/TabSessionStateKtTest.kt
new file mode 100644
index 0000000000..801d577f9f
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/ext/TabSessionStateKtTest.kt
@@ -0,0 +1,129 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray.ext
+
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.concept.storage.HistoryMetadataKey
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.ext.DEFAULT_ACTIVE_DAYS
+import java.util.concurrent.TimeUnit
+
+class TabSessionStateKtTest {
+
+ private val maxTime = TimeUnit.DAYS.toMillis(DEFAULT_ACTIVE_DAYS)
+ private var inactiveTimestamp = 0L
+
+ @Before
+ fun setup() {
+ // Subtracting an extra 10 seconds in case the test runner is loopy.
+ inactiveTimestamp = System.currentTimeMillis() - maxTime - 10_000
+ }
+
+ @Test
+ fun `WHEN tab was recently accessed THEN isActive is true`() {
+ val tab = createTab(
+ url = "https://mozilla.org",
+ lastAccess = System.currentTimeMillis(),
+ createdAt = 0,
+ )
+ assertTrue(tab.isNormalTabActive(maxTime))
+ }
+
+ @Test
+ fun `WHEN tab was recently created THEN isActive is true`() {
+ val tab = createTab(
+ url = "https://mozilla.org",
+ lastAccess = 0,
+ createdAt = System.currentTimeMillis(),
+ )
+ assertTrue(tab.isNormalTabActive(maxTime))
+ }
+
+ @Test
+ fun `WHEN tab either was not created or accessed recently THEN isActive is true`() {
+ val tab = createTab(
+ url = "https://mozilla.org",
+ lastAccess = 0,
+ createdAt = inactiveTimestamp,
+ )
+ assertFalse(tab.isNormalTabActive(maxTime))
+
+ val tab2 = createTab(
+ url = "https://mozilla.org",
+ lastAccess = inactiveTimestamp,
+ createdAt = 0,
+ )
+ assertFalse(tab2.isNormalTabActive(maxTime))
+ }
+
+ @Test
+ fun `WHEN tab has not been accessed or recently created THEN isActive is false`() {
+ val tab = createTab(
+ url = "https://mozilla.org",
+ lastAccess = inactiveTimestamp,
+ createdAt = inactiveTimestamp,
+ )
+ assertFalse(tab.isNormalTabActive(maxTime))
+ }
+
+ @Test
+ fun `WHEN normal tab is recently used THEN return true`() {
+ val tab = createTab(
+ url = "https://mozilla.org",
+ lastAccess = System.currentTimeMillis(),
+ createdAt = System.currentTimeMillis(),
+ private = false,
+ )
+ val test = tab.isNormalTabActive(maxTime)
+ assertTrue(test)
+ }
+
+ @Test
+ fun `WHEN tabs are private THEN always false`() {
+ val tab = createTab(
+ url = "https://mozilla.org",
+ lastAccess = System.currentTimeMillis(),
+ createdAt = System.currentTimeMillis(),
+ private = true,
+ )
+ assertFalse(tab.isNormalTabActive(maxTime))
+ }
+
+ @Test
+ fun `WHEN inactive tabs are private THEN always false`() {
+ val tab = createTab(
+ url = "https://mozilla.org",
+ lastAccess = inactiveTimestamp,
+ createdAt = inactiveTimestamp,
+ private = true,
+ )
+ assertFalse(tab.isNormalTabActive(maxTime))
+ }
+
+ @Test
+ fun `WHEN tab has a search term or metadata THEN return true `() {
+ val tab = createTab(
+ url = "https://mozilla.org",
+ createdAt = System.currentTimeMillis(),
+ historyMetadata = HistoryMetadataKey("https://getpockjet.com", "cats"),
+ )
+ val tab2 = createTab(
+ url = "https://mozilla.org",
+ createdAt = System.currentTimeMillis(),
+ searchTerms = "dogs",
+ )
+ val tab3 = createTab(
+ url = "https://mozilla.org",
+ createdAt = inactiveTimestamp,
+ searchTerms = "dogs",
+ )
+ assertTrue(tab.isNormalTabActiveWithSearchTerm(maxTime))
+ assertTrue(tab2.isNormalTabActiveWithSearchTerm(maxTime))
+ assertFalse(tab3.isNormalTabActiveWithSearchTerm(maxTime))
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/syncedtabs/SyncButtonBindingTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/syncedtabs/SyncButtonBindingTest.kt
new file mode 100644
index 0000000000..f6c2c857d6
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/syncedtabs/SyncButtonBindingTest.kt
@@ -0,0 +1,53 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray.syncedtabs
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.tabstray.TabsTrayAction
+import org.mozilla.fenix.tabstray.TabsTrayStore
+
+class SyncButtonBindingTest {
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Test
+ fun `WHEN syncing state is true THEN invoke callback`() {
+ var invoked = false
+ val store = TabsTrayStore()
+ val binding = SyncButtonBinding(store) { invoked = true }
+
+ binding.start()
+
+ store.dispatch(TabsTrayAction.SyncNow)
+ store.waitUntilIdle()
+
+ assertTrue(invoked)
+ }
+
+ @Test
+ fun `WHEN syncing state is false THEN nothing is invoked`() {
+ var invoked = false
+ val store = TabsTrayStore()
+ val binding = SyncButtonBinding(store) { invoked = true }
+
+ binding.start()
+
+ store.waitUntilIdle()
+
+ assertFalse(invoked)
+
+ store.dispatch(TabsTrayAction.SyncCompleted)
+ store.waitUntilIdle()
+
+ assertFalse(invoked)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabsViewErrorTypeTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabsViewErrorTypeTest.kt
new file mode 100644
index 0000000000..a239e8d742
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabsViewErrorTypeTest.kt
@@ -0,0 +1,40 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray.syncedtabs
+
+import androidx.navigation.NavController
+import io.mockk.mockk
+import mozilla.components.feature.syncedtabs.view.SyncedTabsView
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.tabstray.ext.toSyncedTabsListItem
+
+@RunWith(FenixRobolectricTestRunner::class)
+class SyncedTabsViewErrorTypeTest {
+
+ @Test
+ fun `GIVEN synced tabs error types WHEN the synced tabs update process errors THEN the correct error text should be displayed`() {
+ val context = testContext
+ val navController: NavController = mockk(relaxed = true)
+ val multipleDevicesUnavailable = SyncedTabsView.ErrorType.MULTIPLE_DEVICES_UNAVAILABLE.toSyncedTabsListItem(context, navController)
+ val syncEngineUnavailable = SyncedTabsView.ErrorType.SYNC_ENGINE_UNAVAILABLE.toSyncedTabsListItem(context, navController)
+ val syncNeedsReauthentication = SyncedTabsView.ErrorType.SYNC_NEEDS_REAUTHENTICATION.toSyncedTabsListItem(context, navController)
+ val noTabsAvailable = SyncedTabsView.ErrorType.NO_TABS_AVAILABLE.toSyncedTabsListItem(context, navController)
+ val syncUnavailable = SyncedTabsView.ErrorType.SYNC_UNAVAILABLE.toSyncedTabsListItem(context, navController)
+
+ assertEquals(testContext.getString(R.string.synced_tabs_connect_another_device), multipleDevicesUnavailable.errorText)
+ assertEquals(testContext.getString(R.string.synced_tabs_enable_tab_syncing), syncEngineUnavailable.errorText)
+ assertEquals(testContext.getString(R.string.synced_tabs_reauth), syncNeedsReauthentication.errorText)
+ assertEquals(testContext.getString(R.string.synced_tabs_no_tabs), noTabsAvailable.errorText)
+ assertEquals(testContext.getString(R.string.synced_tabs_sign_in_message), syncUnavailable.errorText)
+ assertNotNull(syncUnavailable.errorButton)
+ assertEquals(testContext.getString(R.string.synced_tabs_sign_in_button), syncUnavailable.errorButton!!.buttonText)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/viewholders/AbstractBrowserPageViewHolderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/viewholders/AbstractBrowserPageViewHolderTest.kt
new file mode 100644
index 0000000000..6ef429aa01
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/viewholders/AbstractBrowserPageViewHolderTest.kt
@@ -0,0 +1,73 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.tabstray.viewholders
+
+import android.view.LayoutInflater
+import android.view.View.GONE
+import android.view.View.VISIBLE
+import android.widget.TextView
+import io.mockk.every
+import io.mockk.mockk
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.tabstray.TabsTrayInteractor
+import org.mozilla.fenix.tabstray.TabsTrayStore
+import org.mozilla.fenix.tabstray.browser.AbstractBrowserTrayList
+import org.mozilla.fenix.tabstray.browser.BrowserTabsAdapter
+
+@RunWith(FenixRobolectricTestRunner::class)
+class AbstractBrowserPageViewHolderTest {
+ val tabsTrayStore: TabsTrayStore = TabsTrayStore()
+ val browserStore = BrowserStore()
+ val interactor = mockk<TabsTrayInteractor>(relaxed = true)
+ init {
+ every { testContext.components.core.thumbnailStorage } returns mockk()
+ every { testContext.components.settings } returns mockk(relaxed = true)
+ }
+
+ val adapter =
+ BrowserTabsAdapter(testContext, interactor, tabsTrayStore, "Test", mockk())
+
+ @Test
+ fun `WHEN tabs inserted THEN show tray`() {
+ val itemView =
+ LayoutInflater.from(testContext).inflate(R.layout.normal_browser_tray_list, null)
+ val viewHolder = PrivateBrowserPageViewHolder(itemView, tabsTrayStore, browserStore, interactor)
+ val trayList: AbstractBrowserTrayList = itemView.findViewById(R.id.tray_list_item)
+ val emptyList: TextView = itemView.findViewById(R.id.tab_tray_empty_view)
+
+ viewHolder.bind(adapter)
+ viewHolder.attachedToWindow()
+
+ adapter.updateTabs(listOf(createTab(url = "url", id = "tab1")), null, "tab1")
+
+ assertTrue(trayList.visibility == VISIBLE)
+ assertTrue(emptyList.visibility == GONE)
+ }
+
+ @Test
+ fun `WHEN no tabs THEN show empty view`() {
+ val itemView =
+ LayoutInflater.from(testContext).inflate(R.layout.normal_browser_tray_list, null)
+ val viewHolder = PrivateBrowserPageViewHolder(itemView, tabsTrayStore, browserStore, interactor)
+ val trayList: AbstractBrowserTrayList = itemView.findViewById(R.id.tray_list_item)
+ val emptyList: TextView = itemView.findViewById(R.id.tab_tray_empty_view)
+
+ viewHolder.bind(adapter)
+ viewHolder.attachedToWindow()
+
+ adapter.updateTabs(emptyList(), null, "")
+
+ assertTrue(trayList.visibility == GONE)
+ assertTrue(emptyList.visibility == VISIBLE)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/telemetry/TelemetryMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/telemetry/TelemetryMiddlewareTest.kt
new file mode 100644
index 0000000000..d609d734e5
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/telemetry/TelemetryMiddlewareTest.kt
@@ -0,0 +1,579 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.telemetry
+
+import androidx.test.core.app.ApplicationProvider
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.runs
+import io.mockk.verify
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.action.EngineAction
+import mozilla.components.browser.state.action.ExtensionsProcessAction
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.action.TranslationsAction
+import mozilla.components.browser.state.engine.EngineMiddleware
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.state.recover.RecoverableTab
+import mozilla.components.browser.state.state.recover.TabState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.Engine
+import mozilla.components.concept.engine.translate.TranslationError
+import mozilla.components.concept.engine.translate.TranslationOperation
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.base.android.Clock
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.telemetry.glean.internal.TimerId
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.Addons
+import org.mozilla.fenix.GleanMetrics.Events
+import org.mozilla.fenix.GleanMetrics.Metrics
+import org.mozilla.fenix.GleanMetrics.Translations
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppAction
+import org.mozilla.fenix.components.metrics.Event
+import org.mozilla.fenix.components.metrics.MetricController
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.utils.Settings
+import org.robolectric.shadows.ShadowLooper
+import org.mozilla.fenix.GleanMetrics.EngineTab as EngineMetrics
+
+private const val SEARCH_ENGINE_NAME = "Test"
+
+@RunWith(FenixRobolectricTestRunner::class)
+class TelemetryMiddlewareTest {
+
+ private lateinit var store: BrowserStore
+ private lateinit var appStore: AppStore
+ private lateinit var settings: Settings
+ private lateinit var telemetryMiddleware: TelemetryMiddleware
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @get:Rule
+ val gleanRule = GleanTestRule(ApplicationProvider.getApplicationContext())
+
+ private val clock = FakeClock()
+ private val metrics: MetricController = mockk()
+ private val searchState: MutableMap<String, TimerId> = mutableMapOf()
+ private val timerId = Metrics.searchPageLoadTime.start()
+
+ @Before
+ fun setUp() {
+ Clock.delegate = clock
+ settings = Settings(testContext)
+ telemetryMiddleware = TelemetryMiddleware(
+ context = testContext,
+ settings = settings,
+ metrics = metrics,
+ nimbusSearchEngine = SEARCH_ENGINE_NAME,
+ searchState = searchState,
+ timerId = timerId,
+ )
+ val engine: Engine = mockk()
+ every { engine.enableExtensionProcessSpawning() } just runs
+ every { engine.disableExtensionProcessSpawning() } just runs
+ every { engine.getSupportedTranslationLanguages(any(), any()) } just runs
+ every { engine.isTranslationsEngineSupported(any(), any()) } just runs
+ store = BrowserStore(
+ middleware = listOf(telemetryMiddleware) + EngineMiddleware.create(engine),
+ initialState = BrowserState(),
+ )
+ appStore = AppStore()
+ every { testContext.components.appStore } returns appStore
+ }
+
+ @After
+ fun tearDown() {
+ Clock.reset()
+ }
+
+ @Test
+ fun `WHEN action is UpdateIsSearchAction & all valid args THEN searchState is updated with session id and timer id`() {
+ assertTrue(searchState.isEmpty())
+
+ val sessionId = "1235"
+ store.dispatch(ContentAction.UpdateIsSearchAction(sessionId, true, SEARCH_ENGINE_NAME))
+ .joinBlocking()
+
+ assertEquals(1, searchState.size)
+ assertEquals(mutableMapOf(sessionId to timerId), searchState)
+ }
+
+ @Test
+ fun `WHEN action is UpdateIsSearchAction & action is not search THEN searchState is not updated`() {
+ assertTrue(searchState.isEmpty())
+
+ val sessionId = "1235"
+ store.dispatch(ContentAction.UpdateIsSearchAction(sessionId, false, SEARCH_ENGINE_NAME))
+ .joinBlocking()
+
+ assertTrue(searchState.isEmpty())
+ }
+
+ @Test
+ fun `WHEN action is UpdateIsSearchAction & search engine name is empty THEN searchState is not updated`() {
+ assertTrue(searchState.isEmpty())
+
+ val sessionId = "1235"
+ store.dispatch(ContentAction.UpdateIsSearchAction(sessionId, true, ""))
+ .joinBlocking()
+
+ assertTrue(searchState.isEmpty())
+ }
+
+ @Test
+ fun `WHEN action is UpdateIsSearchAction & search engine name is different to Nimbus THEN searchState is not updated`() {
+ assertTrue(searchState.isEmpty())
+
+ val sessionId = "1235"
+ store.dispatch(ContentAction.UpdateIsSearchAction(sessionId, true, "$SEARCH_ENGINE_NAME 2"))
+ .joinBlocking()
+
+ assertTrue(searchState.isEmpty())
+ }
+
+ @Test
+ fun `WHEN action is UpdateLoadingStateAction & progress completed THEN telemetry is added & searchState is empty`() {
+ assertNull(Metrics.searchPageLoadTime.testGetValue())
+
+ // Start searchState
+ val sessionId = "1235"
+ store.dispatch(ContentAction.UpdateIsSearchAction(sessionId, true, SEARCH_ENGINE_NAME))
+ .joinBlocking()
+
+ assertEquals(1, searchState.size)
+ assertEquals(mutableMapOf(sessionId to timerId), searchState)
+
+ // Update hasFinishedLoading
+ val tab = createTab(
+ id = sessionId,
+ url = "https://mozilla.org",
+ ).let { it.copy(content = it.content.copy(progress = 100)) }
+
+ assertNull(Events.normalAndPrivateUriCount.testGetValue())
+
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ store.dispatch(ContentAction.UpdateLoadingStateAction(tab.id, true)).joinBlocking()
+ assertNull(Events.normalAndPrivateUriCount.testGetValue())
+
+ store.dispatch(ContentAction.UpdateLoadingStateAction(tab.id, false)).joinBlocking()
+ val count = Events.normalAndPrivateUriCount.testGetValue()!!
+ assertEquals(1, count)
+
+ // Finish searchState
+ assertNotNull(Metrics.searchPageLoadTime.testGetValue())
+ assertTrue(searchState.isEmpty())
+ }
+
+ @Test
+ fun `WHEN action is UpdateLoadingStateAction & progress not completed THEN no telemetry & searchState is empty`() {
+ assertNull(Metrics.searchPageLoadTime.testGetValue())
+
+ // Start searchState
+ val sessionId = "1235"
+ store.dispatch(ContentAction.UpdateIsSearchAction(sessionId, true, SEARCH_ENGINE_NAME))
+ .joinBlocking()
+
+ assertEquals(1, searchState.size)
+ assertEquals(mutableMapOf(sessionId to timerId), searchState)
+
+ // Update hasFinishedLoading
+ val tab = createTab(
+ id = sessionId,
+ url = "https://mozilla.org",
+ ).let { it.copy(content = it.content.copy(progress = 50)) }
+
+ assertNull(Events.normalAndPrivateUriCount.testGetValue())
+
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ store.dispatch(ContentAction.UpdateLoadingStateAction(tab.id, true)).joinBlocking()
+ assertNull(Events.normalAndPrivateUriCount.testGetValue())
+
+ store.dispatch(ContentAction.UpdateLoadingStateAction(tab.id, false)).joinBlocking()
+ val count = Events.normalAndPrivateUriCount.testGetValue()!!
+ assertEquals(1, count)
+
+ // Finish searchState
+ assertNull(Metrics.searchPageLoadTime.testGetValue())
+ assertTrue(searchState.isEmpty())
+ }
+
+ @Test
+ fun `WHEN a tab is added THEN the open tab count is updated`() {
+ assertEquals(0, settings.openTabsCount)
+ assertNull(Metrics.hasOpenTabs.testGetValue())
+
+ store.dispatch(TabListAction.AddTabAction(createTab("https://mozilla.org"))).joinBlocking()
+ assertEquals(1, settings.openTabsCount)
+
+ assertTrue(Metrics.hasOpenTabs.testGetValue()!!)
+ }
+
+ @Test
+ fun `WHEN a private tab is added THEN the open tab count is not updated`() {
+ assertEquals(0, settings.openTabsCount)
+ assertNull(Metrics.hasOpenTabs.testGetValue())
+
+ store.dispatch(TabListAction.AddTabAction(createTab("https://mozilla.org", private = true))).joinBlocking()
+ assertEquals(0, settings.openTabsCount)
+
+ assertFalse(Metrics.hasOpenTabs.testGetValue()!!)
+ }
+
+ @Test
+ fun `WHEN multiple tabs are added THEN the open tab count is updated`() {
+ assertEquals(0, settings.openTabsCount)
+ assertNull(Metrics.hasOpenTabs.testGetValue())
+
+ store.dispatch(
+ TabListAction.AddMultipleTabsAction(
+ listOf(
+ createTab("https://mozilla.org"),
+ createTab("https://firefox.com"),
+ ),
+ ),
+ ).joinBlocking()
+
+ assertEquals(2, settings.openTabsCount)
+
+ assertTrue(Metrics.hasOpenTabs.testGetValue()!!)
+ }
+
+ @Test
+ fun `WHEN a tab is removed THEN the open tab count is updated`() {
+ assertNull(Metrics.hasOpenTabs.testGetValue())
+
+ store.dispatch(
+ TabListAction.AddMultipleTabsAction(
+ listOf(
+ createTab(id = "1", url = "https://mozilla.org"),
+ createTab(id = "2", url = "https://firefox.com"),
+ ),
+ ),
+ ).joinBlocking()
+ assertEquals(2, settings.openTabsCount)
+
+ store.dispatch(TabListAction.RemoveTabAction("1")).joinBlocking()
+ assertEquals(1, settings.openTabsCount)
+
+ assertTrue(Metrics.hasOpenTabs.testGetValue()!!)
+ }
+
+ @Test
+ fun `WHEN all tabs are removed THEN the open tab count is updated`() {
+ assertNull(Metrics.hasOpenTabs.testGetValue())
+
+ store.dispatch(
+ TabListAction.AddMultipleTabsAction(
+ listOf(
+ createTab("https://mozilla.org"),
+ createTab("https://firefox.com"),
+ ),
+ ),
+ ).joinBlocking()
+ assertEquals(2, settings.openTabsCount)
+
+ assertTrue(Metrics.hasOpenTabs.testGetValue()!!)
+
+ store.dispatch(TabListAction.RemoveAllTabsAction()).joinBlocking()
+ assertEquals(0, settings.openTabsCount)
+
+ assertFalse(Metrics.hasOpenTabs.testGetValue()!!)
+ }
+
+ @Test
+ fun `WHEN all normal tabs are removed THEN the open tab count is updated`() {
+ assertNull(Metrics.hasOpenTabs.testGetValue())
+
+ store.dispatch(
+ TabListAction.AddMultipleTabsAction(
+ listOf(
+ createTab("https://mozilla.org"),
+ createTab("https://firefox.com"),
+ createTab("https://getpocket.com", private = true),
+ ),
+ ),
+ ).joinBlocking()
+ assertEquals(2, settings.openTabsCount)
+ assertTrue(Metrics.hasOpenTabs.testGetValue()!!)
+
+ store.dispatch(TabListAction.RemoveAllNormalTabsAction).joinBlocking()
+ assertEquals(0, settings.openTabsCount)
+ assertFalse(Metrics.hasOpenTabs.testGetValue()!!)
+ }
+
+ @Test
+ fun `WHEN tabs are restored THEN the open tab count is updated`() {
+ assertEquals(0, settings.openTabsCount)
+ assertNull(Metrics.hasOpenTabs.testGetValue())
+
+ val tabsToRestore = listOf(
+ RecoverableTab(null, TabState(url = "https://mozilla.org", id = "1")),
+ RecoverableTab(null, TabState(url = "https://firefox.com", id = "2")),
+ )
+
+ store.dispatch(
+ TabListAction.RestoreAction(
+ tabs = tabsToRestore,
+ restoreLocation = TabListAction.RestoreAction.RestoreLocation.BEGINNING,
+ ),
+ ).joinBlocking()
+ assertEquals(2, settings.openTabsCount)
+
+ assertTrue(Metrics.hasOpenTabs.testGetValue()!!)
+ }
+
+ @Test
+ fun `GIVEN a normal page is loading WHEN loading is complete THEN we record a UriOpened event`() {
+ val tab = createTab(id = "1", url = "https://mozilla.org")
+ assertNull(Events.normalAndPrivateUriCount.testGetValue())
+
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ store.dispatch(ContentAction.UpdateLoadingStateAction(tab.id, true)).joinBlocking()
+ assertNull(Events.normalAndPrivateUriCount.testGetValue())
+
+ store.dispatch(ContentAction.UpdateLoadingStateAction(tab.id, false)).joinBlocking()
+ val count = Events.normalAndPrivateUriCount.testGetValue()!!
+ assertEquals(1, count)
+ }
+
+ @Test
+ fun `GIVEN a private page is loading WHEN loading is complete THEN we record a UriOpened event`() {
+ val tab = createTab(id = "1", url = "https://mozilla.org", private = true)
+ assertNull(Events.normalAndPrivateUriCount.testGetValue())
+
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ store.dispatch(ContentAction.UpdateLoadingStateAction(tab.id, true)).joinBlocking()
+ assertNull(Events.normalAndPrivateUriCount.testGetValue())
+
+ store.dispatch(ContentAction.UpdateLoadingStateAction(tab.id, false)).joinBlocking()
+ val count = Events.normalAndPrivateUriCount.testGetValue()!!
+ assertEquals(1, count)
+ }
+
+ @Test
+ fun `WHEN tabs gets killed THEN middleware sends an event`() {
+ store.dispatch(
+ TabListAction.RestoreAction(
+ listOf(
+ RecoverableTab(null, TabState(url = "https://www.mozilla.org", id = "foreground")),
+ RecoverableTab(null, TabState(url = "https://getpocket.com", id = "background_pocket", hasFormData = true)),
+ ),
+ selectedTabId = "foreground",
+ restoreLocation = TabListAction.RestoreAction.RestoreLocation.BEGINNING,
+ ),
+ ).joinBlocking()
+
+ assertNull(EngineMetrics.tabKilled.testGetValue())
+
+ store.dispatch(
+ EngineAction.KillEngineSessionAction("background_pocket"),
+ ).joinBlocking()
+
+ assertEquals(1, EngineMetrics.tabKilled.testGetValue()?.size)
+ EngineMetrics.tabKilled.testGetValue()?.get(0)?.extra?.also {
+ assertEquals("false", it["foreground_tab"])
+ assertEquals("true", it["had_form_data"])
+ assertEquals("true", it["app_foreground"])
+ }
+
+ appStore.dispatch(
+ AppAction.AppLifecycleAction.PauseAction,
+ ).joinBlocking()
+
+ store.dispatch(
+ EngineAction.KillEngineSessionAction("foreground"),
+ ).joinBlocking()
+
+ assertEquals(2, EngineMetrics.tabKilled.testGetValue()?.size)
+ EngineMetrics.tabKilled.testGetValue()?.get(1)?.extra?.also {
+ assertEquals("true", it["foreground_tab"])
+ assertEquals("false", it["had_form_data"])
+ assertEquals("false", it["app_foreground"])
+ }
+ }
+
+ @Test
+ fun `GIVEN the request to check for form data WHEN it fails THEN telemetry is sent`() {
+ assertNull(Events.formDataFailure.testGetValue())
+
+ store.dispatch(
+ ContentAction.CheckForFormDataExceptionAction("1", RuntimeException("session form data request failed")),
+ ).joinBlocking()
+
+ // Wait for the main looper to process the re-thrown exception.
+ ShadowLooper.idleMainLooper()
+
+ assertNotNull(Events.formDataFailure.testGetValue())
+ }
+
+ @Test
+ fun `WHEN uri loaded to engine THEN matching event is sent to metrics`() {
+ store.dispatch(EngineAction.LoadUrlAction("", "")).joinBlocking()
+
+ verify { metrics.track(Event.GrowthData.FirstUriLoadForDay) }
+ }
+
+ @Test
+ fun `WHEN EnabledAction is dispatched THEN enable the process spawning`() {
+ assertNull(Addons.extensionsProcessUiRetry.testGetValue())
+ assertNull(Addons.extensionsProcessUiDisable.testGetValue())
+
+ store.dispatch(ExtensionsProcessAction.EnabledAction).joinBlocking()
+
+ assertEquals(1, Addons.extensionsProcessUiRetry.testGetValue())
+ assertNull(Addons.extensionsProcessUiDisable.testGetValue())
+ }
+
+ @Test
+ fun `WHEN DisabledAction is dispatched THEN disable the process spawning`() {
+ assertNull(Addons.extensionsProcessUiRetry.testGetValue())
+ assertNull(Addons.extensionsProcessUiDisable.testGetValue())
+
+ store.dispatch(ExtensionsProcessAction.DisabledAction).joinBlocking()
+
+ assertEquals(1, Addons.extensionsProcessUiDisable.testGetValue())
+ assertNull(Addons.extensionsProcessUiRetry.testGetValue())
+ }
+
+ @Test
+ fun `WHEN TranslateOfferAction is dispatched THEN update telemetry`() {
+ assertNull(Translations.offerEvent.testGetValue())
+
+ store.dispatch(TranslationsAction.TranslateOfferAction(tabId = "1", true)).joinBlocking()
+
+ val telemetry = Translations.offerEvent.testGetValue()?.firstOrNull()
+ assertEquals("offer", telemetry?.extra?.get("item"))
+ }
+
+ @Test
+ fun `WHEN TranslateExpectedAction is dispatched THEN update telemetry`() {
+ assertNull(Translations.offerEvent.testGetValue())
+
+ store.dispatch(TranslationsAction.TranslateExpectedAction(tabId = "1")).joinBlocking()
+
+ val telemetry = Translations.offerEvent.testGetValue()?.firstOrNull()
+ assertEquals("expected", telemetry?.extra?.get("item"))
+ }
+
+ @Test
+ fun `WHEN TranslateAction is dispatched THEN update telemetry`() {
+ assertNull(Translations.translateRequested.testGetValue())
+
+ store.dispatch(
+ TranslationsAction.TranslateAction(
+ tabId = "1",
+ fromLanguage = "en",
+ toLanguage = "es",
+ options = null,
+ ),
+ ).joinBlocking()
+
+ val telemetry = Translations.translateRequested.testGetValue()?.firstOrNull()
+ assertEquals("es", telemetry?.extra?.get("to_language"))
+ assertEquals("en", telemetry?.extra?.get("from_language"))
+ }
+
+ @Test
+ fun `WHEN TranslateSuccessAction is dispatched THEN update telemetry`() {
+ assertNull(Translations.translateSuccess.testGetValue())
+
+ // Shouldn't record other operations
+ store.dispatch(
+ TranslationsAction.TranslateSuccessAction(
+ tabId = "1",
+ operation = TranslationOperation.FETCH_SUPPORTED_LANGUAGES,
+ ),
+ ).joinBlocking()
+ assertNull(Translations.translateSuccess.testGetValue())
+
+ // Should record translate operations
+ store.dispatch(
+ TranslationsAction.TranslateSuccessAction(
+ tabId = "1",
+ operation = TranslationOperation.TRANSLATE,
+ ),
+ ).joinBlocking()
+
+ val telemetry = Translations.translateSuccess.testGetValue()?.firstOrNull()
+ assertNotNull(telemetry)
+ }
+
+ @Test
+ fun `WHEN TranslateExceptionAction for Translate operation is dispatched THEN update telemetry`() {
+ assertNull(Translations.translateFailed.testGetValue())
+
+ // Shouldn't record other operations
+ store.dispatch(
+ TranslationsAction.TranslateExceptionAction(
+ tabId = "1",
+ operation = TranslationOperation.FETCH_SUPPORTED_LANGUAGES,
+ translationError = TranslationError.UnknownError(IllegalStateException()),
+ ),
+ ).joinBlocking()
+ assertNull(Translations.translateFailed.testGetValue())
+
+ // Should record translate operations
+ store.dispatch(
+ TranslationsAction.TranslateExceptionAction(
+ tabId = "1",
+ operation = TranslationOperation.TRANSLATE,
+ translationError = TranslationError.CouldNotTranslateError(null),
+ ),
+ ).joinBlocking()
+
+ val telemetry = Translations.translateFailed.testGetValue()?.firstOrNull()
+ assertEquals(TranslationError.CouldNotTranslateError(cause = null).errorName, telemetry?.extra?.get("error"))
+ }
+
+ @Test
+ fun `WHEN SetEngineSupportedAction is dispatched AND supported THEN update telemetry`() {
+ assertNull(Translations.engineSupported.testGetValue())
+
+ store.dispatch(
+ TranslationsAction.SetEngineSupportedAction(
+ isEngineSupported = true,
+ ),
+ ).joinBlocking()
+
+ val telemetry = Translations.engineSupported.testGetValue()?.firstOrNull()
+ assertEquals("supported", telemetry?.extra?.get("support"))
+ }
+
+ @Test
+ fun `WHEN SetEngineSupportedAction is dispatched AND unsupported THEN update telemetry`() {
+ assertNull(Translations.engineSupported.testGetValue())
+
+ store.dispatch(
+ TranslationsAction.SetEngineSupportedAction(
+ isEngineSupported = false,
+ ),
+ ).joinBlocking()
+
+ val telemetry = Translations.engineSupported.testGetValue()?.firstOrNull()
+ assertEquals("unsupported", telemetry?.extra?.get("support"))
+ }
+}
+
+internal class FakeClock : Clock.Delegate {
+ var elapsedTime: Long = 0
+ override fun elapsedRealtime(): Long = elapsedTime
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/toolbar/DefaultToolbarMenuTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/toolbar/DefaultToolbarMenuTest.kt
new file mode 100644
index 0000000000..27f1e1b4f1
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/toolbar/DefaultToolbarMenuTest.kt
@@ -0,0 +1,150 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.toolbar
+
+import android.content.Context
+import android.net.Uri
+import androidx.lifecycle.LifecycleOwner
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.spyk
+import io.mockk.unmockkStatic
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.storage.BookmarksStorage
+import mozilla.components.feature.top.sites.PinnedSiteStorage
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Before
+import org.junit.Ignore
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.components.toolbar.DefaultToolbarMenu
+import org.mozilla.fenix.ext.settings
+
+class DefaultToolbarMenuTest {
+
+ private lateinit var store: BrowserStore
+ private lateinit var lifecycleOwner: LifecycleOwner
+ private lateinit var toolbarMenu: DefaultToolbarMenu
+ private lateinit var context: Context
+ private lateinit var bookmarksStorage: BookmarksStorage
+ private lateinit var pinnedSiteStorage: PinnedSiteStorage
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Before
+ fun setUp() {
+ mockkStatic(Uri::class)
+ every { Uri.parse(any()) } returns mockk(relaxed = true)
+
+ lifecycleOwner = mockk(relaxed = true)
+ context = mockk(relaxed = true)
+
+ every { context.theme } returns mockk(relaxed = true)
+
+ bookmarksStorage = mockk(relaxed = true)
+ pinnedSiteStorage = mockk(relaxed = true)
+ store = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab(url = "https://firefox.com", id = "1"),
+ createTab(url = "https://getpocket.com", id = "2"),
+ ),
+ selectedTabId = "1",
+ ),
+ )
+ }
+
+ @After
+ fun tearDown() {
+ unmockkStatic(Uri::class)
+ }
+
+ private fun createMenu() {
+ toolbarMenu = spyk(
+ DefaultToolbarMenu(
+ context = context,
+ store = store,
+ hasAccountProblem = false,
+ onItemTapped = { },
+ lifecycleOwner = lifecycleOwner,
+ pinnedSiteStorage = pinnedSiteStorage,
+ bookmarksStorage = bookmarksStorage,
+ isPinningSupported = false,
+ ),
+ )
+
+ every { toolbarMenu.updateCurrentUrlIsBookmarked(any()) } returns mockk()
+ every { toolbarMenu.shouldShowOpenInApp() } returns mockk()
+ }
+
+ @Test
+ @Ignore("Intermittent test: https://github.com/mozilla-mobile/fenix/issues/18822")
+ fun `WHEN the bottom toolbar is set THEN the first item in the list is not the navigation`() {
+ every { context.settings().shouldUseBottomToolbar } returns true
+ createMenu()
+
+ val menuItems = toolbarMenu.coreMenuItems
+ assertNotNull(menuItems)
+
+ val firstItem = menuItems[0]
+ val newTabItem = toolbarMenu.newTabItem
+
+ assertEquals(newTabItem, firstItem)
+ }
+
+ @Test
+ @Ignore("Intermittent test: https://github.com/mozilla-mobile/fenix/issues/18822")
+ fun `WHEN the top toolbar is set THEN the first item in the list is the navigation`() {
+ every { context.settings().shouldUseBottomToolbar } returns false
+ createMenu()
+
+ val menuItems = toolbarMenu.coreMenuItems
+ assertNotNull(menuItems)
+
+ val firstItem = menuItems[0]
+ val navToolbar = toolbarMenu.menuToolbar
+
+ assertEquals(navToolbar, firstItem)
+ }
+
+ @Test
+ @Ignore("Intermittent test: https://github.com/mozilla-mobile/fenix/issues/18822")
+ fun `WHEN the bottom toolbar is set THEN the nav menu should be the last item`() {
+ every { context.settings().shouldUseBottomToolbar } returns true
+
+ createMenu()
+
+ val menuItems = toolbarMenu.coreMenuItems
+ assertNotNull(menuItems)
+
+ val lastItem = menuItems[menuItems.size - 1]
+ val navToolbar = toolbarMenu.menuToolbar
+
+ assertEquals(navToolbar, lastItem)
+ }
+
+ @Test
+ @Ignore("Intermittent test: https://github.com/mozilla-mobile/fenix/issues/18822")
+ fun `WHEN the top toolbar is set THEN settings should be the last item`() {
+ every { context.settings().shouldUseBottomToolbar } returns false
+
+ createMenu()
+
+ val menuItems = toolbarMenu.coreMenuItems
+ assertNotNull(menuItems)
+
+ val lastItem = menuItems[menuItems.size - 1]
+ val settingsItem = toolbarMenu.settingsItem
+
+ assertEquals(settingsItem, lastItem)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/trackingprotection/ProtectionsStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/trackingprotection/ProtectionsStoreTest.kt
new file mode 100644
index 0000000000..6141970557
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/trackingprotection/ProtectionsStoreTest.kt
@@ -0,0 +1,191 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.trackingprotection
+
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.state.state.SessionState
+import mozilla.components.concept.engine.content.blocking.TrackerLog
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotSame
+import org.junit.Test
+
+class ProtectionsStoreTest {
+
+ val tab: SessionState = mockk(relaxed = true)
+
+ @Test
+ fun enterDetailsMode() = runTest {
+ val initialState = defaultState()
+ val store = ProtectionsStore(initialState)
+
+ store.dispatch(
+ ProtectionsAction.EnterDetailsMode(
+ TrackingProtectionCategory.FINGERPRINTERS,
+ true,
+ ),
+ )
+ .join()
+ assertNotSame(initialState, store.state)
+ assertEquals(
+ store.state.mode,
+ ProtectionsState.Mode.Details(TrackingProtectionCategory.FINGERPRINTERS, true),
+ )
+ assertEquals(store.state.lastAccessedCategory, TrackingProtectionCategory.FINGERPRINTERS.name)
+ }
+
+ @Test
+ fun exitDetailsMode() = runTest {
+ val initialState = detailsState()
+ val store = ProtectionsStore(initialState)
+
+ store.dispatch(ProtectionsAction.ExitDetailsMode).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(
+ store.state.mode,
+ ProtectionsState.Mode.Normal,
+ )
+ assertEquals(store.state.lastAccessedCategory, initialState.lastAccessedCategory)
+ }
+
+ @Test
+ fun trackerListChanged() = runTest {
+ val initialState = defaultState()
+ val store = ProtectionsStore(initialState)
+ val tracker = TrackerLog("url", listOf())
+
+ store.dispatch(ProtectionsAction.TrackerLogChange(listOf(tracker))).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(
+ listOf(tracker),
+ store.state.listTrackers,
+ )
+ }
+
+ @Test
+ fun urlChanged() = runTest {
+ val initialState = defaultState()
+ val store = ProtectionsStore(initialState)
+
+ store.dispatch(ProtectionsAction.UrlChange("newURL")).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(
+ "newURL",
+ store.state.url,
+ )
+ }
+
+ @Test
+ fun onChange() = runTest {
+ val initialState = defaultState()
+ val store = ProtectionsStore(initialState)
+ val tracker = TrackerLog("url", listOf(), listOf(), cookiesHasBeenBlocked = false)
+
+ store.dispatch(
+ ProtectionsAction.Change(
+ "newURL",
+ isTrackingProtectionEnabled = false,
+ cookieBannerUIMode = CookieBannerUIMode.DISABLE,
+ listTrackers = listOf(tracker),
+ mode = ProtectionsState.Mode.Details(
+ TrackingProtectionCategory.FINGERPRINTERS,
+ true,
+ ),
+ ),
+ ).join()
+ assertNotSame(initialState, store.state)
+ assertEquals(
+ "newURL",
+ store.state.url,
+ )
+ assertEquals(
+ false,
+ store.state.isTrackingProtectionEnabled,
+ )
+ assertEquals(
+ CookieBannerUIMode.DISABLE,
+ store.state.cookieBannerUIMode,
+ )
+ assertEquals(
+ listOf(tracker),
+ store.state.listTrackers,
+ )
+ assertEquals(
+ ProtectionsState.Mode.Details(TrackingProtectionCategory.FINGERPRINTERS, true),
+ store.state.mode,
+ )
+ }
+
+ @Test
+ fun `ProtectionsAction - ToggleCookieBannerHandlingProtectionEnabled`() = runTest {
+ val initialState = defaultState()
+ val store = ProtectionsStore(initialState)
+
+ store.dispatch(
+ ProtectionsAction.ToggleCookieBannerHandlingProtectionEnabled(
+ cookieBannerUIMode = CookieBannerUIMode.ENABLE,
+ ),
+ ).join()
+
+ assertEquals(
+ CookieBannerUIMode.ENABLE,
+ store.state.cookieBannerUIMode,
+ )
+ }
+
+ @Test
+ fun `ProtectionsAction - RequestReportSiteDomain`() = runTest {
+ val initialState = defaultState()
+ val store = ProtectionsStore(initialState)
+
+ store.dispatch(
+ ProtectionsAction.RequestReportSiteDomain(
+ url = "youtube.com",
+ ),
+ ).join()
+
+ assertEquals(
+ "youtube.com",
+ store.state.url,
+ )
+ }
+
+ @Test
+ fun `ProtectionsAction - UpdateCookieBannerMode`() = runTest {
+ val initialState = defaultState()
+ val store = ProtectionsStore(initialState)
+
+ store.dispatch(
+ ProtectionsAction.UpdateCookieBannerMode(
+ cookieBannerUIMode = CookieBannerUIMode.DISABLE,
+ ),
+ ).join()
+
+ assertEquals(
+ CookieBannerUIMode.DISABLE,
+ store.state.cookieBannerUIMode,
+ )
+ }
+
+ private fun defaultState(): ProtectionsState = ProtectionsState(
+ tab = tab,
+ url = "www.mozilla.org",
+ isTrackingProtectionEnabled = true,
+ cookieBannerUIMode = CookieBannerUIMode.DISABLE,
+ listTrackers = listOf(),
+ mode = ProtectionsState.Mode.Normal,
+ lastAccessedCategory = "",
+ )
+
+ private fun detailsState(): ProtectionsState = ProtectionsState(
+ tab = tab,
+ url = "www.mozilla.org",
+ isTrackingProtectionEnabled = true,
+ cookieBannerUIMode = CookieBannerUIMode.DISABLE,
+ listTrackers = listOf(),
+ mode = ProtectionsState.Mode.Details(TrackingProtectionCategory.CRYPTOMINERS, true),
+ lastAccessedCategory = TrackingProtectionCategory.CRYPTOMINERS.name,
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/trackingprotection/TrackerBucketsTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/trackingprotection/TrackerBucketsTest.kt
new file mode 100644
index 0000000000..916f84fa75
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/trackingprotection/TrackerBucketsTest.kt
@@ -0,0 +1,111 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.trackingprotection
+
+import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy.TrackingCategory.CRYPTOMINING
+import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy.TrackingCategory.FINGERPRINTING
+import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy.TrackingCategory.MOZILLA_SOCIAL
+import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy.TrackingCategory.SCRIPTS_AND_SUB_RESOURCES
+import mozilla.components.concept.engine.content.blocking.TrackerLog
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.mozilla.fenix.trackingprotection.TrackingProtectionCategory.CRYPTOMINERS
+import org.mozilla.fenix.trackingprotection.TrackingProtectionCategory.FINGERPRINTERS
+
+private typealias FenixTrackingProtectionCategory = TrackingProtectionCategory
+class TrackerBucketsTest {
+
+ @Test
+ fun `initializes with empty map`() {
+ assertTrue(TrackerBuckets().buckets.blockedBucketMap.isEmpty())
+ assertTrue(TrackerBuckets().buckets.loadedBucketMap.isEmpty())
+ }
+
+ @Test
+ fun `getter accesses corresponding bucket`() {
+ val buckets = TrackerBuckets()
+ val google = TrackerLog("https://google.com", listOf(), listOf(FINGERPRINTING))
+ val facebook = TrackerLog("http://facebook.com", listOf(MOZILLA_SOCIAL))
+
+ buckets.updateIfNeeded(
+ listOf(
+ google,
+ facebook,
+ TrackerLog("https://mozilla.com"),
+ ),
+ )
+
+ assertEquals(google, buckets.buckets.blockedBucketMap[FINGERPRINTERS]!!.first())
+ assertEquals(
+ facebook,
+ buckets.buckets.loadedBucketMap[FenixTrackingProtectionCategory.SOCIAL_MEDIA_TRACKERS]!!.first(),
+ )
+ assertTrue(buckets.buckets.blockedBucketMap[CRYPTOMINERS].isNullOrEmpty())
+ assertTrue(buckets.buckets.loadedBucketMap[CRYPTOMINERS].isNullOrEmpty())
+ }
+
+ @Test
+ fun `sorts trackers into bucket`() {
+ val buckets = TrackerBuckets()
+ val google = TrackerLog("https://google.com", listOf(), listOf(FINGERPRINTING))
+ val facebook = TrackerLog("http://facebook.com", listOf(MOZILLA_SOCIAL))
+ val mozilla = TrackerLog("https://mozilla.com")
+ buckets.updateIfNeeded(
+ listOf(
+ facebook,
+ google,
+ mozilla,
+ ),
+ )
+
+ assertEquals(
+ mapOf(
+ FenixTrackingProtectionCategory.SOCIAL_MEDIA_TRACKERS to listOf(facebook),
+ ),
+ buckets.buckets.loadedBucketMap,
+ )
+
+ assertEquals(
+ mapOf(
+ FINGERPRINTERS to listOf(google),
+ ),
+ buckets.buckets.blockedBucketMap,
+ )
+ }
+
+ @Test
+ fun `trackers in the same site but with different categories`() {
+ val buckets = TrackerBuckets()
+ val acCategories = listOf(
+ CRYPTOMINING,
+ MOZILLA_SOCIAL,
+ FINGERPRINTING,
+ SCRIPTS_AND_SUB_RESOURCES,
+ )
+
+ val trackerLog = TrackerLog(
+ url = "http://facebook.com",
+ cookiesHasBeenBlocked = true,
+ blockedCategories = acCategories,
+ loadedCategories = acCategories,
+ )
+ buckets.updateIfNeeded(listOf(trackerLog))
+
+ val expectedBlockedMap =
+ mapOf(
+ FenixTrackingProtectionCategory.SOCIAL_MEDIA_TRACKERS to listOf(trackerLog),
+ FenixTrackingProtectionCategory.TRACKING_CONTENT to listOf(trackerLog),
+ FenixTrackingProtectionCategory.FINGERPRINTERS to listOf(trackerLog),
+ FenixTrackingProtectionCategory.CRYPTOMINERS to listOf(trackerLog),
+ FenixTrackingProtectionCategory.CROSS_SITE_TRACKING_COOKIES to listOf(trackerLog),
+ )
+ val expectedLoadedMap =
+ expectedBlockedMap - FenixTrackingProtectionCategory.CROSS_SITE_TRACKING_COOKIES
+
+ assertEquals(expectedBlockedMap, buckets.buckets.blockedBucketMap)
+ assertEquals(expectedLoadedMap, buckets.buckets.loadedBucketMap)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/trackingprotection/TrackingProtectionBlockingFragmentTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/trackingprotection/TrackingProtectionBlockingFragmentTest.kt
new file mode 100644
index 0000000000..6ebab59df5
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/trackingprotection/TrackingProtectionBlockingFragmentTest.kt
@@ -0,0 +1,79 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.trackingprotection
+
+import android.content.Context
+import androidx.fragment.app.FragmentActivity
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.trackingprotection.TrackingProtectionMode.CUSTOM
+import org.robolectric.Robolectric
+
+@RunWith(FenixRobolectricTestRunner::class)
+class TrackingProtectionBlockingFragmentTest {
+ @Test
+ fun `GIVEN total cookie protection is enabled WHEN showing details about the protection options THEN show update details for tracking protection`() {
+ val expectedTitle = testContext.getString(R.string.etp_cookies_title_2)
+ val expectedDescription = testContext.getString(R.string.etp_cookies_description_2)
+
+ mockkStatic("org.mozilla.fenix.ext.ContextKt") {
+ every { any<Context>().settings() } returns mockk(relaxed = true) {
+ every { enabledTotalCookieProtection } returns true
+ }
+
+ val fragment = createFragment()
+
+ val cookiesCategory = fragment.binding.categoryCookies
+ assertEquals(expectedTitle, cookiesCategory.trackingProtectionCategoryTitle.text)
+ assertEquals(expectedDescription, cookiesCategory.trackingProtectionCategoryItemDescription.text)
+ }
+ }
+
+ @Test
+ fun `GIVEN total cookie protection is not enabled WHEN showing details about the protection options THEN show the default details for tracking protection`() {
+ val expectedTitle = testContext.getString(R.string.etp_cookies_title)
+ val expectedDescription = testContext.getString(R.string.etp_cookies_description)
+
+ mockkStatic("org.mozilla.fenix.ext.ContextKt") {
+ every { any<Context>().settings() } returns mockk(relaxed = true) {
+ every { enabledTotalCookieProtection } returns false
+ }
+
+ val fragment = createFragment()
+
+ val cookiesCategory = fragment.binding.categoryCookies
+ assertEquals(expectedTitle, cookiesCategory.trackingProtectionCategoryTitle.text)
+ assertEquals(expectedDescription, cookiesCategory.trackingProtectionCategoryItemDescription.text)
+ }
+ }
+
+ private fun createFragment(): TrackingProtectionBlockingFragment {
+ // Create and attach the fragment ourself instead of using "createAddedTestFragment"
+ // to prevent having "onResume -> showToolbar" called.
+
+ val activity = Robolectric.buildActivity(FragmentActivity::class.java)
+ .create()
+ .start()
+ .get()
+ val fragment = TrackingProtectionBlockingFragment().apply {
+ arguments = TrackingProtectionBlockingFragmentArgs(
+ protectionMode = CUSTOM,
+ ).toBundle()
+ }
+ activity.supportFragmentManager.beginTransaction()
+ .add(fragment, "test")
+ .commitNow()
+
+ return fragment
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelDialogFragmentTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelDialogFragmentTest.kt
new file mode 100644
index 0000000000..5982e71076
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelDialogFragmentTest.kt
@@ -0,0 +1,137 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.trackingprotection
+
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import junit.framework.TestCase.assertNotSame
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.action.TrackingProtectionAction.TrackerBlockedAction
+import mozilla.components.browser.state.action.TrackingProtectionAction.TrackerLoadedAction
+import mozilla.components.browser.state.selector.findTab
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class TrackingProtectionPanelDialogFragmentTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private lateinit var lifecycleOwner: MockedLifecycleOwner
+ private lateinit var fragment: TrackingProtectionPanelDialogFragment
+ private lateinit var store: BrowserStore
+
+ @Before
+ fun setup() {
+ fragment = spyk(TrackingProtectionPanelDialogFragment())
+ lifecycleOwner = MockedLifecycleOwner(Lifecycle.State.STARTED)
+
+ store = BrowserStore()
+ every { fragment.view } returns mockk(relaxed = true)
+ every { fragment.lifecycle } returns lifecycleOwner.lifecycle
+ every { fragment.activity } returns mockk(relaxed = true)
+ }
+
+ @Test
+ fun `WHEN the url is updated THEN the url view is updated`() {
+ val protectionsStore: ProtectionsStore = mockk(relaxed = true)
+ val tab = createTab("mozilla.org")
+
+ every { fragment.protectionsStore } returns protectionsStore
+ every { fragment.provideCurrentTabId() } returns tab.id
+
+ fragment.observeUrlChange(store)
+ addAndSelectTab(tab)
+
+ verify(exactly = 1) {
+ protectionsStore.dispatch(ProtectionsAction.UrlChange("mozilla.org"))
+ }
+
+ store.dispatch(ContentAction.UpdateUrlAction(tab.id, "wikipedia.org")).joinBlocking()
+
+ verify(exactly = 1) {
+ protectionsStore.dispatch(ProtectionsAction.UrlChange("wikipedia.org"))
+ }
+ }
+
+ @Test
+ fun `WHEN a tracker is loaded THEN trackers view is updated`() {
+ val protectionsStore: ProtectionsStore = mockk(relaxed = true)
+ val tab = createTab("mozilla.org")
+
+ every { fragment.protectionsStore } returns protectionsStore
+ every { fragment.provideCurrentTabId() } returns tab.id
+ every { fragment.updateTrackers(any()) } returns Unit
+
+ fragment.observeTrackersChange(store)
+ addAndSelectTab(tab)
+
+ verify(exactly = 1) {
+ fragment.updateTrackers(tab)
+ }
+
+ store.dispatch(TrackerLoadedAction(tab.id, mockk())).joinBlocking()
+
+ val updatedTab = store.state.findTab(tab.id)!!
+
+ assertNotSame(updatedTab, tab)
+
+ verify(exactly = 1) {
+ fragment.updateTrackers(updatedTab)
+ }
+ }
+
+ @Test
+ fun `WHEN a tracker is blocked THEN trackers view is updated`() {
+ val protectionsStore: ProtectionsStore = mockk(relaxed = true)
+ val tab = createTab("mozilla.org")
+
+ every { fragment.protectionsStore } returns protectionsStore
+ every { fragment.provideCurrentTabId() } returns tab.id
+ every { fragment.updateTrackers(any()) } returns Unit
+
+ fragment.observeTrackersChange(store)
+ addAndSelectTab(tab)
+
+ verify(exactly = 1) {
+ fragment.updateTrackers(tab)
+ }
+
+ store.dispatch(TrackerBlockedAction(tab.id, mockk())).joinBlocking()
+
+ val updatedTab = store.state.findTab(tab.id)!!
+
+ assertNotSame(updatedTab, tab)
+
+ verify(exactly = 1) {
+ fragment.updateTrackers(tab)
+ }
+ }
+
+ private fun addAndSelectTab(tab: TabSessionState) {
+ store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
+ store.dispatch(TabListAction.SelectTabAction(tab.id)).joinBlocking()
+ }
+
+ internal class MockedLifecycleOwner(initialState: Lifecycle.State) : LifecycleOwner {
+ override val lifecycle: Lifecycle = LifecycleRegistry(this).apply {
+ currentState = initialState
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelInteractorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelInteractorTest.kt
new file mode 100644
index 0000000000..4b5b511742
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelInteractorTest.kt
@@ -0,0 +1,155 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.trackingprotection
+
+import android.content.Context
+import androidx.fragment.app.Fragment
+import androidx.navigation.NavController
+import androidx.navigation.NavDirections
+import io.mockk.MockKAnnotations
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.concept.engine.cookiehandling.CookieBannersStorage
+import mozilla.components.concept.engine.permission.SitePermissions
+import mozilla.components.feature.session.TrackingProtectionUseCases
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.settings
+
+class TrackingProtectionPanelInteractorTest {
+
+ private lateinit var context: Context
+
+ @MockK(relaxed = true)
+ private lateinit var navController: NavController
+
+ @MockK(relaxed = true)
+ private lateinit var fragment: Fragment
+
+ @MockK(relaxed = true)
+ private lateinit var sitePermissions: SitePermissions
+
+ @MockK(relaxed = true)
+ private lateinit var store: ProtectionsStore
+
+ private lateinit var interactor: TrackingProtectionPanelInteractor
+
+ private lateinit var tab: TabSessionState
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val scope = coroutinesTestRule.scope
+
+ private var learnMoreClicked = false
+ private var openSettings = false
+ private var gravity = 54
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ learnMoreClicked = false
+
+ context = mockk()
+ tab = createTab("https://mozilla.org")
+ val cookieBannersStorage: CookieBannersStorage = mockk(relaxed = true)
+
+ interactor = TrackingProtectionPanelInteractor(
+ context = context,
+ fragment = fragment,
+ store = store,
+ ioScope = scope,
+ cookieBannersStorage = cookieBannersStorage,
+ navController = { navController },
+ openTrackingProtectionSettings = { openSettings = true },
+ openLearnMoreLink = { learnMoreClicked = true },
+ sitePermissions = sitePermissions,
+ gravity = gravity,
+ getCurrentTab = { tab },
+ )
+
+ val trackingProtectionUseCases: TrackingProtectionUseCases = mockk(relaxed = true)
+
+ every { fragment.context } returns context
+ every { context.components.useCases.trackingProtectionUseCases } returns trackingProtectionUseCases
+
+ val onComplete = slot<(Boolean) -> Unit>()
+ every {
+ trackingProtectionUseCases.containsException.invoke(
+ any(),
+ capture(onComplete),
+ )
+ }.answers { onComplete.captured.invoke(true) }
+ }
+
+ @Test
+ fun `WHEN openDetails is called THEN store should dispatch EnterDetailsMode action with the right category`() {
+ interactor.openDetails(TrackingProtectionCategory.FINGERPRINTERS, true)
+
+ verify {
+ store.dispatch(
+ ProtectionsAction.EnterDetailsMode(
+ TrackingProtectionCategory.FINGERPRINTERS,
+ true,
+ ),
+ )
+ }
+
+ interactor.openDetails(TrackingProtectionCategory.REDIRECT_TRACKERS, true)
+
+ verify {
+ store.dispatch(
+ ProtectionsAction.EnterDetailsMode(
+ TrackingProtectionCategory.REDIRECT_TRACKERS,
+ true,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN selectTrackingProtectionSettings is called THEN openTrackingProtectionSettings should be invoked`() {
+ interactor.selectTrackingProtectionSettings()
+
+ assertEquals(true, openSettings)
+ }
+
+ @Test
+ fun `WHEN on the learn more link is clicked THEN onLearnMoreClicked should be invoked`() {
+ interactor.onLearnMoreClicked()
+
+ assertEquals(true, learnMoreClicked)
+ }
+
+ @Test
+ fun `WHEN onBackPressed is called THEN call popBackStack and navigate`() = runTestOnMain {
+ every { context.settings().shouldUseCookieBannerPrivateMode } returns false
+
+ interactor.onBackPressed()
+
+ coVerify {
+ navController.popBackStack()
+
+ navController.navigate(any<NavDirections>())
+ }
+ }
+
+ @Test
+ fun `WHEN onExitDetailMode is called THEN store should dispatch ExitDetailsMode action`() {
+ interactor.onExitDetailMode()
+
+ verify { store.dispatch(ProtectionsAction.ExitDetailsMode) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelViewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelViewTest.kt
new file mode 100644
index 0000000000..3152334d3f
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelViewTest.kt
@@ -0,0 +1,217 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.trackingprotection
+
+import android.content.Context
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.core.view.isVisible
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.verify
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.TrackingProtection
+import org.mozilla.fenix.R
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.trackingprotection.TrackingProtectionCategory.CROSS_SITE_TRACKING_COOKIES
+import org.mozilla.fenix.trackingprotection.TrackingProtectionCategory.SOCIAL_MEDIA_TRACKERS
+
+@RunWith(FenixRobolectricTestRunner::class)
+class TrackingProtectionPanelViewTest {
+
+ private lateinit var container: ViewGroup
+ private lateinit var interactor: TrackingProtectionPanelInteractor
+ private lateinit var view: TrackingProtectionPanelView
+ private val baseState = ProtectionsState(
+ tab = null,
+ url = "",
+ isTrackingProtectionEnabled = false,
+ cookieBannerUIMode = CookieBannerUIMode.DISABLE,
+ listTrackers = emptyList(),
+ mode = ProtectionsState.Mode.Normal,
+ lastAccessedCategory = "",
+ )
+
+ @get:Rule
+ val gleanRule = GleanTestRule(testContext)
+
+ @Before
+ fun setup() {
+ container = FrameLayout(testContext)
+ interactor = mockk(relaxUnitFun = true)
+ view = TrackingProtectionPanelView(container, interactor)
+ }
+
+ @Test
+ fun testNormalModeUi() {
+ mockkStatic("org.mozilla.fenix.ext.ContextKt") {
+ every { any<Context>().settings() } returns mockk(relaxed = true)
+
+ view.update(baseState.copy(mode = ProtectionsState.Mode.Normal))
+ assertFalse(view.binding.detailsMode.isVisible)
+ assertTrue(view.binding.normalMode.isVisible)
+ assertTrue(view.binding.protectionSettings.isVisible)
+ assertFalse(view.binding.notBlockingHeader.isVisible)
+ assertFalse(view.binding.blockingHeader.isVisible)
+ }
+ }
+
+ @Test
+ fun testNormalModeUiCookiesWithTotalCookieProtectionEnabled() {
+ mockkStatic("org.mozilla.fenix.ext.ContextKt") {
+ every { any<Context>().settings() } returns mockk {
+ every { enabledTotalCookieProtection } returns true
+ }
+ val expectedTitle = testContext.getString(R.string.etp_cookies_title_2)
+
+ view.update(baseState.copy(mode = ProtectionsState.Mode.Normal))
+
+ assertEquals(expectedTitle, view.binding.crossSiteTracking.text)
+ assertEquals(expectedTitle, view.binding.crossSiteTrackingLoaded.text)
+ }
+ }
+
+ @Test
+ fun testNormalModeUiCookiesWithTotalCookieProtectionDisabled() {
+ mockkStatic("org.mozilla.fenix.ext.ContextKt") {
+ every { any<Context>().settings() } returns mockk {
+ every { enabledTotalCookieProtection } returns false
+ }
+ val expectedTitle = testContext.getString(R.string.etp_cookies_title)
+
+ view.update(baseState.copy(mode = ProtectionsState.Mode.Normal))
+
+ assertEquals(expectedTitle, view.binding.crossSiteTracking.text)
+ assertEquals(expectedTitle, view.binding.crossSiteTrackingLoaded.text)
+ }
+ }
+
+ @Test
+ fun testPrivateModeUi() {
+ view.update(
+ baseState.copy(
+ mode = ProtectionsState.Mode.Details(
+ selectedCategory = TrackingProtectionCategory.TRACKING_CONTENT,
+ categoryBlocked = false,
+ ),
+ ),
+ )
+ assertTrue(view.binding.detailsMode.isVisible)
+ assertFalse(view.binding.normalMode.isVisible)
+ assertEquals(
+ testContext.getString(R.string.etp_tracking_content_title),
+ view.binding.categoryTitle.text,
+ )
+ assertEquals(
+ testContext.getString(R.string.etp_tracking_content_description),
+ view.binding.categoryDescription.text,
+ )
+ assertEquals(
+ testContext.getString(R.string.enhanced_tracking_protection_allowed),
+ view.binding.detailsBlockingHeader.text,
+ )
+ }
+
+ @Test
+ fun testPrivateModeUiCookiesWithTotalCookieProtectionEnabled() {
+ mockkStatic("org.mozilla.fenix.ext.ContextKt") {
+ every { any<Context>().settings() } returns mockk {
+ every { enabledTotalCookieProtection } returns true
+ }
+ val expectedTitle = testContext.getString(R.string.etp_cookies_title_2)
+ val expectedDescription = testContext.getString(R.string.etp_cookies_description_2)
+
+ view.update(
+ baseState.copy(
+ mode = ProtectionsState.Mode.Details(
+ selectedCategory = CROSS_SITE_TRACKING_COOKIES,
+ categoryBlocked = false,
+ ),
+ ),
+ )
+
+ assertEquals(expectedTitle, view.binding.categoryTitle.text)
+ assertEquals(expectedDescription, view.binding.categoryDescription.text)
+ }
+ }
+
+ @Test
+ fun testPrivateModeUiCookiesWithTotalCookieProtectionDisabled() {
+ mockkStatic("org.mozilla.fenix.ext.ContextKt") {
+ every { any<Context>().settings() } returns mockk {
+ every { enabledTotalCookieProtection } returns false
+ }
+ val expectedTitle = testContext.getString(R.string.etp_cookies_title)
+ val expectedDescription = testContext.getString(R.string.etp_cookies_description)
+
+ view.update(
+ baseState.copy(
+ mode = ProtectionsState.Mode.Details(
+ selectedCategory = CROSS_SITE_TRACKING_COOKIES,
+ categoryBlocked = false,
+ ),
+ ),
+ )
+
+ assertEquals(expectedTitle, view.binding.categoryTitle.text)
+ assertEquals(expectedDescription, view.binding.categoryDescription.text)
+ }
+ }
+
+ @Test
+ fun testProtectionSettings() {
+ view.binding.protectionSettings.performClick()
+ verify { interactor.selectTrackingProtectionSettings() }
+ }
+
+ @Test
+ fun testExistDetailModed() {
+ view.binding.detailsBack.performClick()
+ verify { interactor.onExitDetailMode() }
+ }
+
+ @Test
+ fun testDetailsBack() {
+ view.binding.navigateBack.performClick()
+ verify { interactor.onBackPressed() }
+ }
+
+ @Test
+ fun testSocialMediaTrackerClick() {
+ every { testContext.components.analytics } returns mockk(relaxed = true)
+ view.binding.socialMediaTrackers.performClick()
+ verify { interactor.openDetails(SOCIAL_MEDIA_TRACKERS, categoryBlocked = true) }
+
+ view.binding.socialMediaTrackersLoaded.performClick()
+ verify { interactor.openDetails(SOCIAL_MEDIA_TRACKERS, categoryBlocked = false) }
+ }
+
+ @Test
+ fun testCrossSiteTrackerClick() {
+ every { testContext.components.analytics } returns mockk(relaxed = true)
+ assertNull(TrackingProtection.etpTrackerList.testGetValue())
+
+ view.binding.crossSiteTracking.performClick()
+
+ assertNotNull(TrackingProtection.etpTrackerList.testGetValue())
+ verify { interactor.openDetails(CROSS_SITE_TRACKING_COOKIES, categoryBlocked = true) }
+
+ view.binding.crossSiteTrackingLoaded.performClick()
+ verify { interactor.openDetails(CROSS_SITE_TRACKING_COOKIES, categoryBlocked = false) }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/translations/TranslationsDialogBindingTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/translations/TranslationsDialogBindingTest.kt
new file mode 100644
index 0000000000..6b860a6212
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/translations/TranslationsDialogBindingTest.kt
@@ -0,0 +1,442 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.translations
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.state.action.TranslationsAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.translate.DetectedLanguages
+import mozilla.components.concept.engine.translate.Language
+import mozilla.components.concept.engine.translate.TranslationDownloadSize
+import mozilla.components.concept.engine.translate.TranslationEngineState
+import mozilla.components.concept.engine.translate.TranslationError
+import mozilla.components.concept.engine.translate.TranslationOperation
+import mozilla.components.concept.engine.translate.TranslationPair
+import mozilla.components.concept.engine.translate.TranslationSupport
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+import org.mozilla.fenix.R
+
+@RunWith(AndroidJUnit4::class)
+class TranslationsDialogBindingTest {
+ @get:Rule
+ val coroutineRule = MainCoroutineRule()
+
+ lateinit var browserStore: BrowserStore
+ private lateinit var translationsDialogStore: TranslationsDialogStore
+
+ private val tabId = "1"
+ private val tab = createTab(url = tabId, id = tabId)
+
+ @Test
+ fun `WHEN fromLanguage and toLanguage get updated in the browserStore THEN translations dialog actions dispatched with the update`() =
+ runTestOnMain {
+ val englishLanguage = Language("en", "English")
+ val spanishLanguage = Language("es", "Spanish")
+ translationsDialogStore = spy(TranslationsDialogStore(TranslationsDialogState()))
+ browserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tabId,
+ ),
+ )
+
+ val binding = TranslationsDialogBinding(
+ browserStore = browserStore,
+ translationsDialogStore = translationsDialogStore,
+ sessionId = tabId,
+ getTranslatedPageTitle = { localizedFrom, localizedTo ->
+ testContext.getString(
+ R.string.translations_bottom_sheet_title_translation_completed,
+ localizedFrom,
+ localizedTo,
+ )
+ },
+ )
+ binding.start()
+
+ val detectedLanguages = DetectedLanguages(
+ documentLangTag = englishLanguage.code,
+ supportedDocumentLang = true,
+ userPreferredLangTag = spanishLanguage.code,
+ )
+
+ val translationEngineState = TranslationEngineState(
+ detectedLanguages = detectedLanguages,
+ error = null,
+ isEngineReady = true,
+ requestedTranslationPair = TranslationPair(
+ fromLanguage = englishLanguage.code,
+ toLanguage = spanishLanguage.code,
+ ),
+ )
+
+ val supportLanguages = TranslationSupport(
+ fromLanguages = listOf(englishLanguage),
+ toLanguages = listOf(spanishLanguage),
+ )
+
+ browserStore.dispatch(
+ TranslationsAction.SetSupportedLanguagesAction(
+ supportedLanguages = supportLanguages,
+ ),
+ ).joinBlocking()
+
+ browserStore.dispatch(
+ TranslationsAction.TranslateStateChangeAction(
+ tabId = tabId,
+ translationEngineState = translationEngineState,
+ ),
+ ).joinBlocking()
+
+ verify(translationsDialogStore).dispatch(
+ TranslationsDialogAction.UpdateFromSelectedLanguage(
+ englishLanguage,
+ ),
+ )
+ verify(translationsDialogStore).dispatch(
+ TranslationsDialogAction.UpdateToSelectedLanguage(
+ spanishLanguage,
+ ),
+ )
+ verify(translationsDialogStore).dispatch(
+ TranslationsDialogAction.UpdateTranslatedPageTitle(
+ testContext.getString(
+ R.string.translations_bottom_sheet_title_translation_completed,
+ englishLanguage.localizedDisplayName,
+ spanishLanguage.localizedDisplayName,
+ ),
+ ),
+ )
+ }
+
+ @Test
+ fun `WHEN translate action is sent to the browserStore THEN update translation dialog store based on operation`() =
+ runTestOnMain {
+ val englishLanguage = Language("en", "English")
+ val spanishLanguage = Language("es", "Spanish")
+ translationsDialogStore = spy(TranslationsDialogStore(TranslationsDialogState()))
+ browserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tabId,
+ ),
+ )
+
+ val binding = TranslationsDialogBinding(
+ browserStore = browserStore,
+ translationsDialogStore = translationsDialogStore,
+ sessionId = tabId,
+ getTranslatedPageTitle = { localizedFrom, localizedTo ->
+ testContext.getString(
+ R.string.translations_bottom_sheet_title_translation_completed,
+ localizedFrom,
+ localizedTo,
+ )
+ },
+ )
+ binding.start()
+
+ browserStore.dispatch(
+ TranslationsAction.TranslateAction(
+ tabId = tabId,
+ fromLanguage = englishLanguage.code,
+ toLanguage = spanishLanguage.code,
+ null,
+ ),
+ ).joinBlocking()
+
+ verify(translationsDialogStore).dispatch(
+ TranslationsDialogAction.UpdateTranslationInProgress(
+ true,
+ ),
+ )
+ verify(translationsDialogStore).dispatch(
+ TranslationsDialogAction.DismissDialog(
+ dismissDialogState = DismissDialogState.WaitingToBeDismissed,
+ ),
+ )
+ }
+
+ @Test
+ fun `WHEN translate from languages list and translate to languages list are sent to the browserStore THEN update translation dialog store based on operation`() =
+ runTestOnMain {
+ translationsDialogStore = spy(TranslationsDialogStore(TranslationsDialogState()))
+ browserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tabId,
+ ),
+ )
+
+ val binding = TranslationsDialogBinding(
+ browserStore = browserStore,
+ translationsDialogStore = translationsDialogStore,
+ sessionId = tabId,
+ getTranslatedPageTitle = { localizedFrom, localizedTo ->
+ testContext.getString(
+ R.string.translations_bottom_sheet_title_translation_completed,
+ localizedFrom,
+ localizedTo,
+ )
+ },
+ )
+ binding.start()
+
+ val toLanguage = Language("de", "German")
+ val fromLanguage = Language("es", "Spanish")
+ val supportedLanguages = TranslationSupport(listOf(fromLanguage), listOf(toLanguage))
+ browserStore.dispatch(
+ TranslationsAction.SetSupportedLanguagesAction(
+ supportedLanguages = supportedLanguages,
+ ),
+ ).joinBlocking()
+
+ verify(translationsDialogStore).dispatch(
+ TranslationsDialogAction.UpdateTranslateFromLanguages(
+ listOf(fromLanguage),
+ ),
+ )
+ verify(translationsDialogStore).dispatch(
+ TranslationsDialogAction.UpdateTranslateToLanguages(
+ listOf(toLanguage),
+ ),
+ )
+ }
+
+ @Test
+ fun `WHEN translate action success is sent to the browserStore THEN update translation dialog store based on operation`() =
+ runTestOnMain {
+ translationsDialogStore =
+ spy(TranslationsDialogStore(TranslationsDialogState(dismissDialogState = DismissDialogState.WaitingToBeDismissed)))
+ browserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tabId,
+ ),
+ )
+
+ val binding = TranslationsDialogBinding(
+ browserStore = browserStore,
+ translationsDialogStore = translationsDialogStore,
+ sessionId = tabId,
+ getTranslatedPageTitle = { localizedFrom, localizedTo ->
+ testContext.getString(
+ R.string.translations_bottom_sheet_title_translation_completed,
+ localizedFrom,
+ localizedTo,
+ )
+ },
+ )
+ binding.start()
+
+ browserStore.dispatch(
+ TranslationsAction.TranslateSuccessAction(
+ tabId = tab.id,
+ operation = TranslationOperation.TRANSLATE,
+ ),
+ ).joinBlocking()
+
+ verify(translationsDialogStore).dispatch(
+ TranslationsDialogAction.UpdateTranslated(
+ true,
+ ),
+ )
+ verify(translationsDialogStore).dispatch(
+ TranslationsDialogAction.UpdateTranslationInProgress(
+ false,
+ ),
+ )
+ verify(translationsDialogStore).dispatch(
+ TranslationsDialogAction.DismissDialog(
+ dismissDialogState = DismissDialogState.Dismiss,
+ ),
+ )
+ }
+
+ @Test
+ fun `WHEN translate fetch error is sent to the browserStore THEN update translation dialog store based on operation`() =
+ runTestOnMain {
+ translationsDialogStore =
+ spy(TranslationsDialogStore(TranslationsDialogState()))
+ browserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tabId,
+ ),
+ )
+
+ val binding = TranslationsDialogBinding(
+ browserStore = browserStore,
+ translationsDialogStore = translationsDialogStore,
+ sessionId = tabId,
+ getTranslatedPageTitle = { localizedFrom, localizedTo ->
+ testContext.getString(
+ R.string.translations_bottom_sheet_title_translation_completed,
+ localizedFrom,
+ localizedTo,
+ )
+ },
+ )
+ binding.start()
+
+ val fetchError = TranslationError.CouldNotLoadLanguagesError(null)
+ browserStore.dispatch(
+ TranslationsAction.TranslateExceptionAction(
+ tabId = tab.id,
+ operation = TranslationOperation.FETCH_SUPPORTED_LANGUAGES,
+ translationError = fetchError,
+ ),
+ ).joinBlocking()
+
+ verify(translationsDialogStore).dispatch(
+ TranslationsDialogAction.UpdateTranslationError(fetchError),
+ )
+ }
+
+ @Test
+ fun `WHEN a non-displayable error is sent to the browserStore THEN the translation dialog store is not updated`() =
+ runTestOnMain {
+ translationsDialogStore =
+ spy(TranslationsDialogStore(TranslationsDialogState()))
+ browserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tabId,
+ ),
+ )
+
+ val binding = TranslationsDialogBinding(
+ browserStore = browserStore,
+ translationsDialogStore = translationsDialogStore,
+ sessionId = tabId,
+ getTranslatedPageTitle = { localizedFrom, localizedTo ->
+ testContext.getString(
+ R.string.translations_bottom_sheet_title_translation_completed,
+ localizedFrom,
+ localizedTo,
+ )
+ },
+ )
+ binding.start()
+
+ val fetchError = TranslationError.UnknownEngineSupportError(null)
+ browserStore.dispatch(
+ TranslationsAction.EngineExceptionAction(
+ error = fetchError,
+ ),
+ ).joinBlocking()
+
+ verify(translationsDialogStore, never()).dispatch(
+ TranslationsDialogAction.UpdateTranslationError(fetchError),
+ )
+ }
+
+ @Test
+ fun `WHEN a browser and session error is sent to the browserStore THEN the session error takes priority and the translation dialog store is updated`() =
+ runTestOnMain {
+ translationsDialogStore =
+ spy(TranslationsDialogStore(TranslationsDialogState()))
+ browserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tabId,
+ ),
+ )
+
+ val binding = TranslationsDialogBinding(
+ browserStore = browserStore,
+ translationsDialogStore = translationsDialogStore,
+ sessionId = tabId,
+ getTranslatedPageTitle = { localizedFrom, localizedTo ->
+ testContext.getString(
+ R.string.translations_bottom_sheet_title_translation_completed,
+ localizedFrom,
+ localizedTo,
+ )
+ },
+ )
+ binding.start()
+
+ val sessionError = TranslationError.CouldNotLoadLanguagesError(null)
+ browserStore.dispatch(
+ TranslationsAction.TranslateExceptionAction(
+ tabId = tab.id,
+ operation = TranslationOperation.FETCH_SUPPORTED_LANGUAGES,
+ translationError = sessionError,
+ ),
+ ).joinBlocking()
+
+ verify(translationsDialogStore).dispatch(
+ TranslationsDialogAction.UpdateTranslationError(sessionError),
+ )
+
+ val engineError = TranslationError.UnknownError(IllegalStateException())
+ browserStore.dispatch(
+ TranslationsAction.EngineExceptionAction(
+ error = engineError,
+ ),
+ ).joinBlocking()
+
+ verify(translationsDialogStore, never()).dispatch(
+ TranslationsDialogAction.UpdateTranslationError(engineError),
+ )
+ }
+
+ @Test
+ fun `WHEN set translation download size action sent to the browserStore THEN update translation dialog store based on operation`() =
+ runTestOnMain {
+ translationsDialogStore =
+ spy(TranslationsDialogStore(TranslationsDialogState()))
+ browserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(tab),
+ selectedTabId = tabId,
+ ),
+ )
+
+ val binding = TranslationsDialogBinding(
+ browserStore = browserStore,
+ translationsDialogStore = translationsDialogStore,
+ sessionId = tabId,
+ getTranslatedPageTitle = { localizedFrom, localizedTo ->
+ testContext.getString(
+ R.string.translations_bottom_sheet_title_translation_completed,
+ localizedFrom,
+ localizedTo,
+ )
+ },
+ )
+ binding.start()
+
+ val toLanguage = Language("de", "German")
+ val fromLanguage = Language("es", "Spanish")
+ val translationDownloadSize = TranslationDownloadSize(
+ fromLanguage = fromLanguage,
+ toLanguage = toLanguage,
+ size = 1000L,
+ )
+ browserStore.dispatch(
+ TranslationsAction.SetTranslationDownloadSizeAction(
+ tabId = tab.id,
+ translationSize = translationDownloadSize,
+ ),
+ ).joinBlocking()
+
+ verify(translationsDialogStore).dispatch(
+ TranslationsDialogAction.UpdateDownloadTranslationDownloadSize(translationDownloadSize),
+ )
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/translations/TranslationsDialogMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/translations/TranslationsDialogMiddlewareTest.kt
new file mode 100644
index 0000000000..c03c74de5c
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/translations/TranslationsDialogMiddlewareTest.kt
@@ -0,0 +1,259 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.translations
+
+import android.content.Context
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.state.action.TranslationsAction
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.translate.Language
+import mozilla.components.concept.engine.translate.TranslationOperation
+import mozilla.components.concept.engine.translate.TranslationPageSettingOperation
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class TranslationsDialogMiddlewareTest {
+ private val browserStore = mockk<BrowserStore>(relaxed = true)
+ private val context = mockk<Context>(relaxed = true)
+ private val settings = Settings(testContext)
+ private val translationsDialogMiddleware =
+ TranslationsDialogMiddleware(browserStore = browserStore, sessionId = "tab1", settings = settings)
+
+ @Test
+ fun `GIVEN translationState WHEN FetchSupportedLanguages action is called THEN call OperationRequestedAction from BrowserStore`() =
+ runTest {
+ val translationStore = TranslationsDialogStore(
+ initialState = TranslationsDialogState(),
+ middlewares = listOf(translationsDialogMiddleware),
+ )
+ translationStore.dispatch(TranslationsDialogAction.FetchSupportedLanguages)
+ .joinBlocking()
+
+ translationStore.waitUntilIdle()
+
+ verify {
+ browserStore.dispatch(
+ TranslationsAction.OperationRequestedAction(
+ tabId = "tab1",
+ operation = TranslationOperation.FETCH_SUPPORTED_LANGUAGES,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN translationState WHEN TranslateAction from TranslationDialogStore is called THEN call TranslateAction from BrowserStore`() =
+ runTest {
+ val translationStore = TranslationsDialogStore(
+ initialState = TranslationsDialogState(
+ initialFrom = Language("en", "English"),
+ initialTo = Language("fr", "France"),
+ ),
+ middlewares = listOf(translationsDialogMiddleware),
+ )
+ translationStore.dispatch(TranslationsDialogAction.TranslateAction).joinBlocking()
+
+ translationStore.waitUntilIdle()
+
+ verify {
+ browserStore.dispatch(
+ TranslationsAction.TranslateAction(
+ tabId = "tab1",
+ fromLanguage = "en",
+ toLanguage = "fr",
+ options = null,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN translationState WHEN RestoreTranslation from TranslationDialogStore is called THEN call TranslateRestoreAction from BrowserStore`() =
+ runTest {
+ val translationStore = TranslationsDialogStore(
+ initialState = TranslationsDialogState(),
+ middlewares = listOf(translationsDialogMiddleware),
+ )
+ translationStore.dispatch(TranslationsDialogAction.RestoreTranslation).joinBlocking()
+
+ translationStore.waitUntilIdle()
+
+ verify {
+ browserStore.dispatch(
+ TranslationsAction.TranslateRestoreAction(
+ tabId = "tab1",
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN translationState WHEN FetchDownloadFileSizeAction from TranslationDialogStore is called THEN call FetchTranslationDownloadSizeAction from BrowserStore`() =
+ runTest {
+ val translationStore = TranslationsDialogStore(
+ initialState = TranslationsDialogState(),
+ middlewares = listOf(translationsDialogMiddleware),
+ )
+ translationStore.dispatch(
+ TranslationsDialogAction.FetchDownloadFileSizeAction(
+ toLanguage = Language("en", "English"),
+ fromLanguage = Language("fr", "France"),
+ ),
+ ).joinBlocking()
+
+ translationStore.waitUntilIdle()
+
+ verify {
+ browserStore.dispatch(
+ TranslationsAction.FetchTranslationDownloadSizeAction(
+ tabId = "tab1",
+ fromLanguage = Language("fr", "France"),
+ toLanguage = Language("en", "English"),
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN translationState WHEN FetchPageSettings from TranslationDialogStore is called THEN call FETCH_PAGE_SETTINGS from BrowserStore`() =
+ runTest {
+ val translationStore = TranslationsDialogStore(
+ initialState = TranslationsDialogState(),
+ middlewares = listOf(translationsDialogMiddleware),
+ )
+ translationStore.dispatch(TranslationsDialogAction.FetchPageSettings).joinBlocking()
+
+ translationStore.waitUntilIdle()
+
+ verify {
+ browserStore.dispatch(
+ TranslationsAction.OperationRequestedAction(
+ tabId = "tab1",
+ operation = TranslationOperation.FETCH_PAGE_SETTINGS,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN translationState WHEN UpdatePageSettingsValue with action type AlwaysOfferPopup from TranslationDialogStore is called THEN call UpdatePageSettingAction from BrowserStore`() =
+ runTest {
+ assertTrue(settings.offerTranslation)
+ val translationStore = TranslationsDialogStore(
+ initialState = TranslationsDialogState(),
+ middlewares = listOf(translationsDialogMiddleware),
+ )
+ translationStore.dispatch(
+ TranslationsDialogAction.UpdatePageSettingsValue(
+ type = TranslationPageSettingsOption.AlwaysOfferPopup(),
+ checkValue = false,
+ ),
+ ).joinBlocking()
+
+ translationStore.waitUntilIdle()
+
+ verify {
+ browserStore.dispatch(
+ TranslationsAction.UpdatePageSettingAction(
+ tabId = "tab1",
+ operation = TranslationPageSettingOperation.UPDATE_ALWAYS_OFFER_POPUP,
+ setting = false,
+ ),
+ )
+ }
+ assertFalse(settings.offerTranslation)
+ }
+
+ @Test
+ fun `GIVEN translationState WHEN UpdatePageSettingsValue with action type AlwaysTranslateLanguage from TranslationDialogStore is called THEN call UpdatePageSettingAction from BrowserStore`() =
+ runTest {
+ val translationStore = TranslationsDialogStore(
+ initialState = TranslationsDialogState(),
+ middlewares = listOf(translationsDialogMiddleware),
+ )
+ translationStore.dispatch(
+ TranslationsDialogAction.UpdatePageSettingsValue(
+ type = TranslationPageSettingsOption.AlwaysTranslateLanguage(),
+ checkValue = false,
+ ),
+ ).joinBlocking()
+
+ translationStore.waitUntilIdle()
+
+ verify {
+ browserStore.dispatch(
+ TranslationsAction.UpdatePageSettingAction(
+ tabId = "tab1",
+ operation = TranslationPageSettingOperation.UPDATE_ALWAYS_TRANSLATE_LANGUAGE,
+ setting = false,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN translationState WHEN UpdatePageSettingsValue with action type NeverTranslateLanguage from TranslationDialogStore is called THEN call UpdatePageSettingAction from BrowserStore`() =
+ runTest {
+ val translationStore = TranslationsDialogStore(
+ initialState = TranslationsDialogState(),
+ middlewares = listOf(translationsDialogMiddleware),
+ )
+ translationStore.dispatch(
+ TranslationsDialogAction.UpdatePageSettingsValue(
+ type = TranslationPageSettingsOption.NeverTranslateLanguage(),
+ checkValue = true,
+ ),
+ ).joinBlocking()
+
+ translationStore.waitUntilIdle()
+
+ verify {
+ browserStore.dispatch(
+ TranslationsAction.UpdatePageSettingAction(
+ tabId = "tab1",
+ operation = TranslationPageSettingOperation.UPDATE_NEVER_TRANSLATE_LANGUAGE,
+ setting = true,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `GIVEN translationState WHEN UpdatePageSettingsValue with action type NeverTranslateSite from TranslationDialogStore is called THEN call UpdatePageSettingAction from BrowserStore`() =
+ runTest {
+ val translationStore = TranslationsDialogStore(
+ initialState = TranslationsDialogState(),
+ middlewares = listOf(translationsDialogMiddleware),
+ )
+ translationStore.dispatch(
+ TranslationsDialogAction.UpdatePageSettingsValue(
+ type = TranslationPageSettingsOption.NeverTranslateSite(),
+ checkValue = false,
+ ),
+ ).joinBlocking()
+
+ translationStore.waitUntilIdle()
+
+ verify {
+ browserStore.dispatch(
+ TranslationsAction.UpdatePageSettingAction(
+ tabId = "tab1",
+ operation = TranslationPageSettingOperation.UPDATE_NEVER_TRANSLATE_SITE,
+ setting = false,
+ ),
+ )
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/translations/TranslationsDialogReducerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/translations/TranslationsDialogReducerTest.kt
new file mode 100644
index 0000000000..983810efce
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/translations/TranslationsDialogReducerTest.kt
@@ -0,0 +1,254 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.translations
+
+import mozilla.components.concept.engine.translate.Language
+import mozilla.components.concept.engine.translate.TranslationDownloadSize
+import mozilla.components.concept.engine.translate.TranslationError
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class TranslationsDialogReducerTest {
+
+ @Test
+ fun `WHEN the reducer is called for UpdateFromSelectedLanguage THEN a new state with updated fromSelectedLanguage is returned`() {
+ val spanishLanguage = Language("es", "Spanish")
+ val englishLanguage = Language("en", "English")
+
+ val translationsDialogState = TranslationsDialogState(initialTo = spanishLanguage)
+
+ val updatedState = TranslationsDialogReducer.reduce(
+ translationsDialogState,
+ TranslationsDialogAction.UpdateFromSelectedLanguage(englishLanguage),
+ )
+
+ assertEquals(englishLanguage, updatedState.initialFrom)
+ assertEquals(PositiveButtonType.Enabled, updatedState.positiveButtonType)
+
+ val updatedStateTwo = TranslationsDialogReducer.reduce(
+ translationsDialogState,
+ TranslationsDialogAction.UpdateFromSelectedLanguage(spanishLanguage),
+ )
+
+ assertEquals(PositiveButtonType.Disabled, updatedStateTwo.positiveButtonType)
+ }
+
+ @Test
+ fun `WHEN the reducer is called for UpdateToSelectedLanguage THEN a new state with updated toSelectedLanguage is returned`() {
+ val spanishLanguage = Language("es", "Spanish")
+ val englishLanguage = Language("en", "English")
+
+ val translationsDialogState = TranslationsDialogState(initialFrom = spanishLanguage)
+
+ val updatedState = TranslationsDialogReducer.reduce(
+ translationsDialogState,
+ TranslationsDialogAction.UpdateToSelectedLanguage(englishLanguage),
+ )
+
+ assertEquals(englishLanguage, updatedState.initialTo)
+ assertEquals(PositiveButtonType.Enabled, updatedState.positiveButtonType)
+
+ val updatedStateTwo = TranslationsDialogReducer.reduce(
+ translationsDialogState,
+ TranslationsDialogAction.UpdateToSelectedLanguage(spanishLanguage),
+ )
+
+ assertEquals(PositiveButtonType.Disabled, updatedStateTwo.positiveButtonType)
+ }
+
+ @Test
+ fun `WHEN the reducer is called for UpdateTranslateToLanguages THEN a new state with updated translateToLanguages is returned`() {
+ val spanishLanguage = Language("es", "Spanish")
+ val englishLanguage = Language("en", "English")
+
+ val translationsDialogState = TranslationsDialogState()
+
+ val updatedState = TranslationsDialogReducer.reduce(
+ translationsDialogState,
+ TranslationsDialogAction.UpdateTranslateToLanguages(
+ listOf(
+ spanishLanguage,
+ englishLanguage,
+ ),
+ ),
+ )
+
+ assertEquals(listOf(spanishLanguage, englishLanguage), updatedState.toLanguages)
+ }
+
+ @Test
+ fun `WHEN the reducer is called for UpdateTranslateFromLanguages THEN a new state with updated translatefromLanguages is returned`() {
+ val spanishLanguage = Language("es", "Spanish")
+ val englishLanguage = Language("en", "English")
+
+ val translationsDialogState = TranslationsDialogState()
+
+ val updatedState = TranslationsDialogReducer.reduce(
+ translationsDialogState,
+ TranslationsDialogAction.UpdateTranslateFromLanguages(
+ listOf(
+ spanishLanguage,
+ englishLanguage,
+ ),
+ ),
+ )
+
+ assertEquals(listOf(spanishLanguage, englishLanguage), updatedState.fromLanguages)
+ }
+
+ @Test
+ fun `WHEN the reducer is called for DismissDialog THEN a new state with updated dismiss dialog is returned`() {
+ val translationsDialogState = TranslationsDialogState()
+
+ val updatedState = TranslationsDialogReducer.reduce(
+ translationsDialogState,
+ TranslationsDialogAction.DismissDialog(DismissDialogState.Dismiss),
+ )
+
+ assertEquals(DismissDialogState.Dismiss, updatedState.dismissDialogState)
+ }
+
+ @Test
+ fun `WHEN the reducer is called for UpdateInProgress THEN a new state with translation in progress is returned`() {
+ val translationsDialogState = TranslationsDialogState()
+
+ val updatedState = TranslationsDialogReducer.reduce(
+ translationsDialogState,
+ TranslationsDialogAction.UpdateTranslationInProgress(true),
+ )
+
+ assertEquals(true, updatedState.isTranslationInProgress)
+ assertEquals(PositiveButtonType.InProgress, updatedState.positiveButtonType)
+ }
+
+ @Test
+ fun `WHEN the reducer is called for UpdateTranslationError THEN a new state with translation error is returned`() {
+ val translationsDialogState = TranslationsDialogState()
+
+ val updatedState = TranslationsDialogReducer.reduce(
+ translationsDialogState,
+ TranslationsDialogAction.UpdateTranslationError(
+ translationError = TranslationError.LanguageNotSupportedError(
+ null,
+ ),
+ documentLangDisplayName = "German",
+ ),
+ )
+
+ assertTrue(updatedState.error is TranslationError.LanguageNotSupportedError)
+ assertNull(updatedState.positiveButtonType)
+ assertEquals(updatedState.documentLangDisplayName, "German")
+
+ val updatedStateTwo = TranslationsDialogReducer.reduce(
+ translationsDialogState,
+ TranslationsDialogAction.UpdateTranslationError(
+ TranslationError.CouldNotLoadLanguagesError(
+ null,
+ ),
+ ),
+ )
+
+ assertTrue(updatedStateTwo.error is TranslationError.CouldNotLoadLanguagesError)
+ assertNull(updatedStateTwo.positiveButtonType)
+ }
+
+ @Test
+ fun `WHEN the reducer is called for UpdateTranslated THEN a new state with translation translated is returned`() {
+ val translationsDialogState = TranslationsDialogState()
+
+ val updatedState = TranslationsDialogReducer.reduce(
+ translationsDialogState,
+ TranslationsDialogAction.UpdateTranslated(
+ true,
+ ),
+ )
+
+ assertEquals(PositiveButtonType.Disabled, updatedState.positiveButtonType)
+ assertEquals(true, updatedState.isTranslated)
+ }
+
+ @Test
+ fun `WHEN the reducer is called for UpdateTranslatedPageTitle THEN a new state with translation title is returned`() {
+ val spanishLanguage = Language("es", "Spanish")
+ val englishLanguage = Language("en", "English")
+ val translationsDialogState =
+ TranslationsDialogState(initialTo = englishLanguage, initialFrom = spanishLanguage)
+
+ val updatedState = TranslationsDialogReducer.reduce(
+ translationsDialogState,
+ TranslationsDialogAction.UpdateTranslatedPageTitle(
+ testContext.getString(
+ R.string.translations_bottom_sheet_title_translation_completed,
+ spanishLanguage.localizedDisplayName,
+ englishLanguage.localizedDisplayName,
+ ),
+ ),
+ )
+
+ assertEquals(
+ testContext.getString(
+ R.string.translations_bottom_sheet_title_translation_completed,
+ spanishLanguage.localizedDisplayName,
+ englishLanguage.localizedDisplayName,
+ ),
+ updatedState.translatedPageTitle,
+ )
+ }
+
+ @Test
+ fun `WHEN the reducer is called for UpdateDownloadTranslationDownloadSize THEN a new state with translationDownloadSize is returned`() {
+ val spanishLanguage = Language("es", "Spanish")
+ val englishLanguage = Language("en", "English")
+ val translationsDialogState =
+ TranslationsDialogState(initialTo = englishLanguage, initialFrom = spanishLanguage)
+ val translationDownloadSize = TranslationDownloadSize(
+ fromLanguage = spanishLanguage,
+ toLanguage = englishLanguage,
+ size = 1000L,
+ )
+ val updatedState = TranslationsDialogReducer.reduce(
+ translationsDialogState,
+ TranslationsDialogAction.UpdateDownloadTranslationDownloadSize(
+ translationDownloadSize,
+ ),
+ )
+
+ assertEquals(
+ translationDownloadSize,
+ updatedState.translationDownloadSize,
+ )
+ }
+
+ @Test
+ fun `WHEN the reducer is called for UpdateDownloadTranslationDownloadSize with a invalid object THEN a new state with translationDownloadSize null is returned`() {
+ val spanishLanguage = Language("es", "Spanish")
+ val englishLanguage = Language("en", "English")
+ val translationsDialogState =
+ TranslationsDialogState(initialTo = englishLanguage, initialFrom = spanishLanguage)
+ val translationDownloadSize = TranslationDownloadSize(
+ fromLanguage = englishLanguage,
+ toLanguage = spanishLanguage,
+ size = 0L,
+ )
+ val updatedState = TranslationsDialogReducer.reduce(
+ translationsDialogState,
+ TranslationsDialogAction.UpdateDownloadTranslationDownloadSize(
+ translationDownloadSize,
+ ),
+ )
+
+ assertEquals(
+ null,
+ updatedState.translationDownloadSize,
+ )
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/translations/preferences/downloadlanguages/DownloadLanguagesFeatureTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/translations/preferences/downloadlanguages/DownloadLanguagesFeatureTest.kt
new file mode 100644
index 0000000000..aeff654080
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/translations/preferences/downloadlanguages/DownloadLanguagesFeatureTest.kt
@@ -0,0 +1,126 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.fenix.translations.preferences.downloadlanguages
+
+import android.net.ConnectivityManager
+import android.os.Build
+import androidx.core.net.ConnectivityManagerCompat
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito
+import org.mozilla.fenix.wifi.WifiConnectionMonitor
+import org.robolectric.annotation.Config
+
+@RunWith(AndroidJUnit4::class)
+class DownloadLanguagesFeatureTest {
+ private lateinit var downloadLanguagesFeature: DownloadLanguagesFeature
+ private lateinit var wifiConnectionMonitor: WifiConnectionMonitor
+ private lateinit var dataSaverAndWifiChanged: ((Boolean) -> Unit)
+ private lateinit var connectivityManager: ConnectivityManager
+
+ @Before
+ fun setUp() {
+ wifiConnectionMonitor = mockk(relaxed = true)
+ dataSaverAndWifiChanged = mock()
+ connectivityManager = mockk()
+ downloadLanguagesFeature =
+ DownloadLanguagesFeature(
+ context = testContext,
+ wifiConnectionMonitor = wifiConnectionMonitor,
+ onDataSaverAndWifiChanged = dataSaverAndWifiChanged,
+ )
+
+ downloadLanguagesFeature.connectivityManager = mockk()
+ }
+
+ @Test
+ fun `GIVEN fragment is added WHEN the feature starts THEN listen for wifi changes`() {
+ downloadLanguagesFeature.start()
+
+ verify(exactly = 1) {
+ wifiConnectionMonitor.start()
+ }
+ verify(exactly = 1) {
+ wifiConnectionMonitor.addOnWifiConnectedChangedListener(
+ downloadLanguagesFeature.wifiConnectedListener,
+ )
+ }
+ Assert.assertNotNull(downloadLanguagesFeature.connectivityManager)
+ }
+
+ @Test
+ fun `WHEN stopping the feature THEN all listeners will be removed`() {
+ downloadLanguagesFeature.stop()
+
+ verify(exactly = 1) {
+ wifiConnectionMonitor.stop()
+ }
+ verify(exactly = 1) {
+ wifiConnectionMonitor.removeOnWifiConnectedChangedListener(
+ downloadLanguagesFeature.wifiConnectedListener,
+ )
+ }
+ Assert.assertNull(downloadLanguagesFeature.connectivityManager)
+ }
+
+ @Test
+ @Config(sdk = [Build.VERSION_CODES.N])
+ fun `GIVEN wifi is connected WHEN wifi changes to not connected and restrictBackgroundStatus is RESTRICT_BACKGROUND_STATUS_ENABLED THEN onDataSaverAndWifiChanged callback should return true`() {
+ every { connectivityManager.restrictBackgroundStatus } returns
+ ConnectivityManagerCompat.RESTRICT_BACKGROUND_STATUS_ENABLED
+ downloadLanguagesFeature.start()
+ downloadLanguagesFeature.connectivityManager = connectivityManager
+
+ downloadLanguagesFeature.wifiConnectedListener(false)
+
+ Mockito.verify(dataSaverAndWifiChanged).invoke(true)
+ }
+
+ @Test
+ @Config(sdk = [Build.VERSION_CODES.N])
+ fun `GIVEN wifi is connected WHEN wifi changes to not connected and restrictBackgroundStatus is RESTRICT_BACKGROUND_STATUS_WHITELISTED THEN onDataSaverAndWifiChanged callback should return true`() {
+ every { connectivityManager.restrictBackgroundStatus } returns
+ ConnectivityManagerCompat.RESTRICT_BACKGROUND_STATUS_WHITELISTED
+ downloadLanguagesFeature.start()
+ downloadLanguagesFeature.connectivityManager = connectivityManager
+
+ downloadLanguagesFeature.wifiConnectedListener(false)
+
+ Mockito.verify(dataSaverAndWifiChanged).invoke(true)
+ }
+
+ @Test
+ @Config(sdk = [Build.VERSION_CODES.N])
+ fun `GIVEN wifi is connected WHEN wifi changes to connected and restrictBackgroundStatus is RESTRICT_BACKGROUND_STATUS_WHITELISTED THEN onDataSaverAndWifiChanged callback should return false`() {
+ every { connectivityManager.restrictBackgroundStatus } returns
+ ConnectivityManagerCompat.RESTRICT_BACKGROUND_STATUS_WHITELISTED
+ downloadLanguagesFeature.start()
+ downloadLanguagesFeature.connectivityManager = connectivityManager
+
+ downloadLanguagesFeature.wifiConnectedListener(true)
+
+ Mockito.verify(dataSaverAndWifiChanged).invoke(false)
+ }
+
+ @Test
+ @Config(sdk = [Build.VERSION_CODES.N])
+ fun `GIVEN wifi is connected WHEN wifi changes to connected and restrictBackgroundStatus is RESTRICT_BACKGROUND_STATUS_DISABLED THEN onDataSaverAndWifiChanged callback should return false`() {
+ every { connectivityManager.restrictBackgroundStatus } returns
+ ConnectivityManagerCompat.RESTRICT_BACKGROUND_STATUS_DISABLED
+ downloadLanguagesFeature.start()
+ downloadLanguagesFeature.connectivityManager = connectivityManager
+
+ downloadLanguagesFeature.wifiConnectedListener(true)
+
+ Mockito.verify(dataSaverAndWifiChanged).invoke(false)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/utils/ClipboardHandlerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/utils/ClipboardHandlerTest.kt
new file mode 100644
index 0000000000..0842c930a3
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/utils/ClipboardHandlerTest.kt
@@ -0,0 +1,107 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.utils
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import io.mockk.every
+import io.mockk.mockkObject
+import io.mockk.spyk
+import io.mockk.unmockkObject
+import io.mockk.verify
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.utils.SafeUrl
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class ClipboardHandlerTest {
+
+ private val clipboardUrl = "https://www.mozilla.org"
+ private val clipboardText = "Mozilla"
+ private lateinit var clipboard: ClipboardManager
+ private lateinit var clipboardHandler: ClipboardHandler
+
+ @Before
+ fun setup() {
+ clipboard = testContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ clipboardHandler = ClipboardHandler(testContext)
+ }
+
+ @Test
+ fun getText() {
+ assertEquals(null, clipboardHandler.text)
+
+ clipboard.setPrimaryClip(ClipData.newPlainText("Text", clipboardText))
+ assertEquals(clipboardText, clipboardHandler.text)
+ }
+
+ @Test
+ fun setText() {
+ assertEquals(null, clipboardHandler.text)
+
+ clipboardHandler.text = clipboardText
+ assertEquals(clipboardText, clipboardHandler.text)
+ }
+
+ @Test
+ fun `extract url from plaintext mime clipboard clip`() {
+ assertEquals(null, clipboardHandler.extractURL())
+
+ clipboard.setPrimaryClip(ClipData.newPlainText("Text", clipboardUrl))
+ assertEquals(clipboardUrl, clipboardHandler.extractURL())
+ }
+
+ @Test
+ fun `extract url from html mime clipboard clip`() {
+ assertEquals(null, clipboardHandler.extractURL())
+
+ clipboard.setPrimaryClip(ClipData.newHtmlText("Html", clipboardUrl, clipboardUrl))
+ assertEquals(clipboardUrl, clipboardHandler.extractURL())
+ }
+
+ @Test
+ fun `extract url from url mime clipboard clip`() {
+ assertEquals(null, clipboardHandler.extractURL())
+
+ clipboard.setPrimaryClip(
+ ClipData(clipboardUrl, arrayOf("text/x-moz-url"), ClipData.Item(clipboardUrl)),
+ )
+ assertEquals(clipboardUrl, clipboardHandler.extractURL())
+ }
+
+ @Test
+ fun `text should return firstSafePrimaryClipItemText`() {
+ val safeResult = "safeResult"
+ clipboard.setPrimaryClip(ClipData.newPlainText(clipboardUrl, clipboardText))
+ clipboardHandler = spyk(clipboardHandler)
+ every { clipboardHandler getProperty "firstSafePrimaryClipItemText" } propertyType String::class returns safeResult
+
+ val result = clipboardHandler.text
+
+ verify { clipboardHandler getProperty "firstSafePrimaryClipItemText" }
+ assertEquals(safeResult, result)
+ }
+
+ @Test
+ fun `firstSafePrimaryClipItemText should return the result of SafeUrl#stripUnsafeUrlSchemes`() {
+ mockkObject(SafeUrl)
+ try {
+ every { SafeUrl.stripUnsafeUrlSchemes(any(), any()) } returns "safeResult"
+ clipboard.setPrimaryClip(ClipData.newHtmlText("Html", clipboardUrl, clipboardUrl))
+
+ val result = clipboardHandler.firstSafePrimaryClipItemText
+
+ verify { SafeUrl.stripUnsafeUrlSchemes(testContext, clipboardUrl) }
+ assertEquals("safeResult", result)
+ } finally {
+ unmockkObject(SafeUrl)
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/utils/LocaleUtilsTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/utils/LocaleUtilsTest.kt
new file mode 100644
index 0000000000..0046f2927c
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/utils/LocaleUtilsTest.kt
@@ -0,0 +1,32 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.utils
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import java.util.Locale
+
+@RunWith(FenixRobolectricTestRunner::class)
+class LocaleUtilsTest {
+
+ @Test
+ fun `WHEN using getDisplayName on a 'de' locale THEN get the expected default name`() {
+ val localizedLanguageName = LocaleUtils.getDisplayName(
+ locale = Locale("de"),
+ )
+ assertEquals("Deutsch", localizedLanguageName)
+ }
+
+ @Test
+ fun `WHEN using getLocalizedDisplayName with an 'en' locale on a 'de' locale THEN get the expected localized name`() {
+ val localizedLanguageName = LocaleUtils.getLocalizedDisplayName(
+ userLocale = Locale("en"),
+ languageLocale = Locale("de"),
+ )
+ assertEquals("German", localizedLanguageName)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/utils/SettingsTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/utils/SettingsTest.kt
new file mode 100644
index 0000000000..7006be6081
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/utils/SettingsTest.kt
@@ -0,0 +1,1007 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.utils
+
+import io.mockk.every
+import io.mockk.spyk
+import mozilla.components.concept.engine.Engine.HttpsOnlyMode.DISABLED
+import mozilla.components.concept.engine.Engine.HttpsOnlyMode.ENABLED
+import mozilla.components.concept.engine.Engine.HttpsOnlyMode.ENABLED_PRIVATE_ONLY
+import mozilla.components.feature.sitepermissions.SitePermissionsRules
+import mozilla.components.feature.sitepermissions.SitePermissionsRules.Action.ALLOWED
+import mozilla.components.feature.sitepermissions.SitePermissionsRules.Action.ASK_TO_ALLOW
+import mozilla.components.feature.sitepermissions.SitePermissionsRules.Action.BLOCKED
+import mozilla.components.feature.sitepermissions.SitePermissionsRules.AutoplayAction
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.browser.browsingmode.BrowsingMode
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.settings.PhoneFeature
+import org.mozilla.fenix.settings.deletebrowsingdata.DeleteBrowsingDataOnQuitType
+import java.util.Calendar
+
+@RunWith(FenixRobolectricTestRunner::class)
+class SettingsTest {
+
+ lateinit var settings: Settings
+
+ private val defaultPermissions = SitePermissionsRules(
+ camera = ASK_TO_ALLOW,
+ location = ASK_TO_ALLOW,
+ microphone = ASK_TO_ALLOW,
+ notification = ASK_TO_ALLOW,
+ autoplayAudible = AutoplayAction.BLOCKED,
+ autoplayInaudible = AutoplayAction.ALLOWED,
+ persistentStorage = ASK_TO_ALLOW,
+ mediaKeySystemAccess = ASK_TO_ALLOW,
+ crossOriginStorageAccess = ASK_TO_ALLOW,
+ )
+
+ @Before
+ fun setUp() {
+ settings = Settings(testContext)
+ }
+
+ @Test
+ fun launchLinksInPrivateTab() {
+ // When just created
+ // Then
+ assertFalse(settings.openLinksInAPrivateTab)
+
+ // When
+ settings.openLinksInAPrivateTab = true
+
+ // Then
+ assertTrue(settings.openLinksInAPrivateTab)
+ }
+
+ @Test
+ fun shouldReturnToBrowser() {
+ // When just created
+ // Then
+ assertFalse(settings.shouldReturnToBrowser)
+
+ // When
+ settings.shouldReturnToBrowser = true
+
+ // Then
+ assertTrue(settings.shouldReturnToBrowser)
+ }
+
+ @Test
+ fun clearDataOnQuit() {
+ // When just created
+ // Then
+ assertFalse(settings.shouldDeleteBrowsingDataOnQuit)
+
+ // When
+ settings.shouldDeleteBrowsingDataOnQuit = true
+
+ // Then
+ assertTrue(settings.shouldDeleteBrowsingDataOnQuit)
+
+ // When
+ settings.shouldDeleteBrowsingDataOnQuit = false
+
+ // Then
+ assertFalse(settings.shouldDeleteBrowsingDataOnQuit)
+ }
+
+ @Test
+ fun clearAnyDataOnQuit() {
+ // When just created
+ // Then
+ assertFalse(settings.shouldDeleteAnyDataOnQuit())
+
+ // When
+ settings.setDeleteDataOnQuit(DeleteBrowsingDataOnQuitType.TABS, true)
+
+ // Then
+ assertTrue(settings.shouldDeleteAnyDataOnQuit())
+
+ // When
+ settings.setDeleteDataOnQuit(DeleteBrowsingDataOnQuitType.PERMISSIONS, true)
+
+ // Then
+ assertTrue(settings.shouldDeleteAnyDataOnQuit())
+
+ // When
+ settings.setDeleteDataOnQuit(DeleteBrowsingDataOnQuitType.TABS, false)
+ settings.setDeleteDataOnQuit(DeleteBrowsingDataOnQuitType.PERMISSIONS, false)
+
+ // Then
+ assertFalse(settings.shouldDeleteAnyDataOnQuit())
+ }
+
+ @Test
+ fun defaultSearchEngineName() {
+ // When just created
+ // Then
+ assertEquals("", settings.defaultSearchEngineName)
+
+ // When
+ settings.defaultSearchEngineName = "Mozilla"
+
+ // Then
+ assertEquals("Mozilla", settings.defaultSearchEngineName)
+ }
+
+ @Test
+ fun isRemoteDebuggingEnabled() {
+ // When just created
+ // Then
+ assertFalse(settings.isRemoteDebuggingEnabled)
+ }
+
+ @Test
+ fun canShowCfrTest() {
+ // When just created
+ // Then
+ assertEquals(0L, settings.lastCfrShownTimeInMillis)
+ assertTrue(settings.canShowCfr)
+
+ // When
+ settings.lastCfrShownTimeInMillis = System.currentTimeMillis()
+
+ // Then
+ assertFalse(settings.canShowCfr)
+ }
+
+ @Test
+ fun isTelemetryEnabled() {
+ // When just created
+ // Then
+ assertTrue(settings.isTelemetryEnabled)
+ }
+
+ @Test
+ fun showLoginsDialogWarningSync() {
+ // When just created
+ // Then
+ assertEquals(0, settings.loginsSecureWarningSyncCount.value)
+
+ // When
+ settings.incrementShowLoginsSecureWarningSyncCount()
+
+ // Then
+ assertEquals(1, settings.loginsSecureWarningSyncCount.value)
+ }
+
+ @Test
+ fun shouldShowLoginsDialogWarningSync() {
+ // When just created
+ // Then
+ assertTrue(settings.shouldShowSecurityPinWarningSync)
+
+ // When
+ settings.incrementShowLoginsSecureWarningSyncCount()
+
+ // Then
+ assertFalse(settings.shouldShowSecurityPinWarningSync)
+ }
+
+ @Test
+ fun showLoginsDialogWarning() {
+ // When just created
+ // Then
+ assertEquals(0, settings.secureWarningCount.value)
+
+ // When
+ settings.incrementSecureWarningCount()
+
+ // Then
+ assertEquals(1, settings.secureWarningCount.value)
+ }
+
+ @Test
+ fun shouldShowLoginsDialogWarning() {
+ // When just created
+ // Then
+ assertTrue(settings.shouldShowSecurityPinWarning)
+
+ // When
+ settings.incrementSecureWarningCount()
+
+ // Then
+ assertFalse(settings.shouldShowSecurityPinWarning)
+ }
+
+ @Test
+ fun shouldUseLightTheme() {
+ // When just created
+ // Then
+ assertFalse(settings.shouldUseLightTheme)
+
+ // When
+ settings.shouldUseLightTheme = true
+
+ // Then
+ assertTrue(settings.shouldUseLightTheme)
+ }
+
+ @Test
+ fun shouldManuallyCloseTabs() {
+ // When just created
+ // Then
+ assertTrue(settings.manuallyCloseTabs)
+
+ // When
+ settings.manuallyCloseTabs = false
+
+ // Then
+ assertFalse(settings.manuallyCloseTabs)
+ }
+
+ @Test
+ fun getTabTimeout() {
+ // When just created
+ // Then
+ assertTrue(settings.manuallyCloseTabs)
+ assertEquals(Long.MAX_VALUE, settings.getTabTimeout())
+
+ // When
+ settings.manuallyCloseTabs = false
+ settings.closeTabsAfterOneDay = true
+
+ // Then
+ assertEquals(Settings.ONE_DAY_MS, settings.getTabTimeout())
+
+ // When
+ settings.closeTabsAfterOneDay = false
+ settings.closeTabsAfterOneWeek = true
+
+ // Then
+ assertEquals(Settings.ONE_WEEK_MS, settings.getTabTimeout())
+
+ // When
+ settings.closeTabsAfterOneWeek = false
+ settings.closeTabsAfterOneMonth = true
+
+ // Then
+ assertEquals(Settings.ONE_MONTH_MS, settings.getTabTimeout())
+ }
+
+ @Test
+ fun shouldUseAutoSize() {
+ // When just created
+ // Then
+ assertTrue(settings.shouldUseAutoSize)
+
+ // When
+ settings.shouldUseAutoSize = false
+
+ // Then
+ assertFalse(settings.shouldUseAutoSize)
+ }
+
+ @Test
+ fun shouldAutofill() {
+ // When just created
+ // Then
+ assertTrue(settings.shouldAutofillLogins)
+
+ // When
+ settings.shouldAutofillLogins = false
+
+ // Then
+ assertFalse(settings.shouldAutofillLogins)
+ }
+
+ @Test
+ fun fontSizeFactor() {
+ // When just created
+ // Then
+ assertEquals(1f, settings.fontSizeFactor)
+
+ // When
+ settings.fontSizeFactor = 2f
+
+ // Then
+ assertEquals(2f, settings.fontSizeFactor)
+ }
+
+ @Test
+ fun shouldShowClipboardSuggestion() {
+ // When just created
+ // Then
+ assertTrue(settings.shouldShowClipboardSuggestions)
+ }
+
+ @Test
+ fun shouldShowSearchShortcuts() {
+ // When just created
+ // Then
+ assertFalse(settings.shouldShowSearchShortcuts)
+ }
+
+ @Test
+ fun shouldShowHistorySuggestions() {
+ // When just created
+ // Then
+ assertTrue(settings.shouldShowHistorySuggestions)
+ }
+
+ @Test
+ fun shouldShowBookmarkSuggestions() {
+ // When just created
+ // Then
+ assertTrue(settings.shouldShowBookmarkSuggestions)
+ }
+
+ @Test
+ fun shouldUseDarkTheme() {
+ // When just created
+ // Then
+ assertFalse(settings.shouldUseDarkTheme)
+ }
+
+ @Test
+ fun shouldFollowDeviceTheme() {
+ // When just created
+ // Then
+ assertFalse(settings.shouldFollowDeviceTheme)
+
+ // When
+ settings.shouldFollowDeviceTheme = true
+
+ // Then
+ assertTrue(settings.shouldFollowDeviceTheme)
+ }
+
+ @Test
+ fun shouldUseTrackingProtection() {
+ // When
+ // Then
+ assertTrue(settings.shouldUseTrackingProtection)
+
+ // When
+ settings.shouldUseTrackingProtection = false
+
+ // Then
+ assertFalse(settings.shouldUseTrackingProtection)
+ }
+
+ @Test
+ fun shouldShowCollectionsPlaceholderOnHome() {
+ // When
+ // Then
+ assertTrue(settings.showCollectionsPlaceholderOnHome)
+
+ // When
+ settings.showCollectionsPlaceholderOnHome = false
+
+ // Then
+ assertFalse(settings.showCollectionsPlaceholderOnHome)
+ }
+
+ @Test
+ fun shouldSetOpenInAppOpened() {
+ // When
+ // Then
+ assertFalse(settings.openInAppOpened)
+
+ // When
+ settings.openInAppOpened = true
+
+ // Then
+ assertTrue(settings.openInAppOpened)
+ }
+
+ @Test
+ fun shouldSetInstallPwaOpened() {
+ // When
+ // Then
+ assertFalse(settings.installPwaOpened)
+
+ // When
+ settings.installPwaOpened = true
+
+ // Then
+ assertTrue(settings.installPwaOpened)
+ }
+
+ @Test
+ fun shouldUseTrackingProtectionStrict() {
+ // When
+ // Then
+ assertFalse(settings.useStrictTrackingProtection)
+ }
+
+ @Test
+ fun shouldUseAutoBatteryTheme() {
+ // When just created
+ // Then
+ assertFalse(settings.shouldUseAutoBatteryTheme)
+ }
+
+ @Test
+ fun showSearchSuggestions() {
+ // When just created
+ // Then
+ assertTrue(settings.shouldShowSearchSuggestions)
+ }
+
+ @Test
+ fun showPwaFragment() {
+ // When just created
+ // Then
+ assertFalse(settings.shouldShowPwaCfr)
+
+ // When visited once
+ settings.incrementVisitedInstallableCount()
+
+ // Then
+ assertFalse(settings.shouldShowPwaCfr)
+
+ // When visited twice
+ settings.incrementVisitedInstallableCount()
+
+ // Then
+ assertFalse(settings.shouldShowPwaCfr)
+
+ // When visited thrice
+ settings.incrementVisitedInstallableCount()
+
+ // Then
+ assertTrue(settings.shouldShowPwaCfr)
+ }
+
+ @Test
+ fun sitePermissionsPhoneFeatureCameraAction() {
+ // When just created
+ // Then
+ assertEquals(
+ ASK_TO_ALLOW,
+ settings.getSitePermissionsPhoneFeatureAction(PhoneFeature.CAMERA),
+ )
+
+ // When
+ settings.setSitePermissionsPhoneFeatureAction(PhoneFeature.CAMERA, BLOCKED)
+
+ // Then
+ assertEquals(BLOCKED, settings.getSitePermissionsPhoneFeatureAction(PhoneFeature.CAMERA))
+ }
+
+ @Test
+ fun sitePermissionsPhoneFeatureMicrophoneAction() {
+ // When just created
+ // Then
+ assertEquals(
+ ASK_TO_ALLOW,
+ settings.getSitePermissionsPhoneFeatureAction(PhoneFeature.MICROPHONE),
+ )
+
+ // When
+ settings.setSitePermissionsPhoneFeatureAction(PhoneFeature.MICROPHONE, BLOCKED)
+
+ // Then
+ assertEquals(
+ BLOCKED,
+ settings.getSitePermissionsPhoneFeatureAction(PhoneFeature.MICROPHONE),
+ )
+ }
+
+ @Test
+ fun sitePermissionsPhoneFeatureNotificationAction() {
+ // When just created
+ // Then
+ assertEquals(
+ ASK_TO_ALLOW,
+ settings.getSitePermissionsPhoneFeatureAction(PhoneFeature.NOTIFICATION),
+ )
+
+ // When
+ settings.setSitePermissionsPhoneFeatureAction(PhoneFeature.NOTIFICATION, BLOCKED)
+
+ // Then
+ assertEquals(
+ BLOCKED,
+ settings.getSitePermissionsPhoneFeatureAction(PhoneFeature.NOTIFICATION),
+ )
+ }
+
+ @Test
+ fun sitePermissionsPhoneFeatureLocation() {
+ // When just created
+ // Then
+ assertEquals(
+ ASK_TO_ALLOW,
+ settings.getSitePermissionsPhoneFeatureAction(PhoneFeature.LOCATION),
+ )
+
+ // When
+ settings.setSitePermissionsPhoneFeatureAction(PhoneFeature.LOCATION, BLOCKED)
+
+ // Then
+ assertEquals(BLOCKED, settings.getSitePermissionsPhoneFeatureAction(PhoneFeature.LOCATION))
+ }
+
+ @Test
+ fun getSitePermissionsCustomSettingsRules_default() {
+ // When just created
+ // Then
+ assertEquals(
+ defaultPermissions,
+ settings.getSitePermissionsCustomSettingsRules(),
+ )
+ }
+
+ @Test
+ fun getSitePermissionsCustomSettingsRules_camera() {
+ // When
+ settings.setSitePermissionsPhoneFeatureAction(PhoneFeature.CAMERA, BLOCKED)
+
+ // Then
+ assertEquals(
+ defaultPermissions.copy(camera = BLOCKED),
+ settings.getSitePermissionsCustomSettingsRules(),
+ )
+ }
+
+ @Test
+ fun getSitePermissionsCustomSettingsRules_notification() {
+ // When
+ settings.setSitePermissionsPhoneFeatureAction(PhoneFeature.NOTIFICATION, BLOCKED)
+
+ // Then
+ assertEquals(
+ defaultPermissions.copy(notification = BLOCKED),
+ settings.getSitePermissionsCustomSettingsRules(),
+ )
+ }
+
+ @Test
+ fun getSitePermissionsCustomSettingsRules_location() {
+ // When
+ settings.setSitePermissionsPhoneFeatureAction(PhoneFeature.LOCATION, BLOCKED)
+
+ // Then
+ assertEquals(
+ defaultPermissions.copy(location = BLOCKED),
+ settings.getSitePermissionsCustomSettingsRules(),
+ )
+ }
+
+ @Test
+ fun getSitePermissionsCustomSettingsRules_microphone() {
+ // When
+ settings.setSitePermissionsPhoneFeatureAction(PhoneFeature.MICROPHONE, BLOCKED)
+
+ // Then
+ assertEquals(
+ defaultPermissions.copy(microphone = BLOCKED),
+ settings.getSitePermissionsCustomSettingsRules(),
+ )
+ }
+
+ @Test
+ fun getSitePermissionsCustomSettingsRules_autoplayAudible() {
+ settings.setSitePermissionsPhoneFeatureAction(PhoneFeature.AUTOPLAY_AUDIBLE, ALLOWED)
+
+ assertEquals(
+ defaultPermissions.copy(autoplayAudible = AutoplayAction.ALLOWED),
+ settings.getSitePermissionsCustomSettingsRules(),
+ )
+ }
+
+ @Test
+ fun getSitePermissionsCustomSettingsRules_autoplayInaudible() {
+ settings.setSitePermissionsPhoneFeatureAction(PhoneFeature.AUTOPLAY_INAUDIBLE, ALLOWED)
+
+ assertEquals(
+ defaultPermissions.copy(autoplayInaudible = AutoplayAction.ALLOWED),
+ settings.getSitePermissionsCustomSettingsRules(),
+ )
+ }
+
+ @Test
+ fun getSitePermissionsCustomSettingsRules_autoplay_defaults() {
+ val settings = Settings(testContext)
+
+ assertEquals(
+ AutoplayAction.BLOCKED,
+ settings.getSitePermissionsCustomSettingsRules().autoplayAudible,
+ )
+
+ assertEquals(
+ AutoplayAction.ALLOWED,
+ settings.getSitePermissionsCustomSettingsRules().autoplayInaudible,
+ )
+ }
+
+ @Test
+ fun getSitePermissionsCustomSettingsRules_persistentStorage() {
+ settings.setSitePermissionsPhoneFeatureAction(PhoneFeature.PERSISTENT_STORAGE, ALLOWED)
+
+ assertEquals(
+ defaultPermissions.copy(persistentStorage = ALLOWED),
+ settings.getSitePermissionsCustomSettingsRules(),
+ )
+
+ settings.setSitePermissionsPhoneFeatureAction(PhoneFeature.PERSISTENT_STORAGE, BLOCKED)
+
+ assertEquals(
+ defaultPermissions.copy(persistentStorage = BLOCKED),
+ settings.getSitePermissionsCustomSettingsRules(),
+ )
+ }
+
+ @Test
+ fun getSitePermissionsCustomSettingsRules_crossOriginStorageAccess() {
+ settings.setSitePermissionsPhoneFeatureAction(PhoneFeature.CROSS_ORIGIN_STORAGE_ACCESS, ALLOWED)
+
+ assertEquals(
+ defaultPermissions.copy(crossOriginStorageAccess = ALLOWED),
+ settings.getSitePermissionsCustomSettingsRules(),
+ )
+
+ settings.setSitePermissionsPhoneFeatureAction(PhoneFeature.CROSS_ORIGIN_STORAGE_ACCESS, BLOCKED)
+
+ assertEquals(
+ defaultPermissions.copy(crossOriginStorageAccess = BLOCKED),
+ settings.getSitePermissionsCustomSettingsRules(),
+ )
+ }
+
+ @Test
+ fun getSitePermissionsCustomSettingsRules_mediaKeySystemAccess() {
+ settings.setSitePermissionsPhoneFeatureAction(PhoneFeature.MEDIA_KEY_SYSTEM_ACCESS, ALLOWED)
+
+ assertEquals(
+ defaultPermissions.copy(mediaKeySystemAccess = ALLOWED),
+ settings.getSitePermissionsCustomSettingsRules(),
+ )
+
+ settings.setSitePermissionsPhoneFeatureAction(PhoneFeature.MEDIA_KEY_SYSTEM_ACCESS, BLOCKED)
+
+ assertEquals(
+ defaultPermissions.copy(mediaKeySystemAccess = BLOCKED),
+ settings.getSitePermissionsCustomSettingsRules(),
+ )
+ }
+
+ @Test
+ fun overrideAmoCollection() {
+ // When just created
+ // Then
+ assertEquals("", settings.overrideAmoCollection)
+ assertFalse(settings.amoCollectionOverrideConfigured())
+
+ // When
+ settings.overrideAmoCollection = "testCollection"
+
+ // Then
+ assertEquals("testCollection", settings.overrideAmoCollection)
+ assertTrue(settings.amoCollectionOverrideConfigured())
+ }
+
+ @Test
+ fun overrideAmoUser() {
+ // When just created
+ // Then
+ assertEquals("", settings.overrideAmoUser)
+ assertFalse(settings.amoCollectionOverrideConfigured())
+
+ // When
+ settings.overrideAmoUser = "testAmoUser"
+
+ // Then
+ assertEquals("testAmoUser", settings.overrideAmoUser)
+ assertTrue(settings.amoCollectionOverrideConfigured())
+ }
+
+ @Test
+ fun `GIVEN startOnHomeAlways is selected WHEN calling shouldStartOnHome THEN return true`() {
+ settings.alwaysOpenTheHomepageWhenOpeningTheApp = true
+ settings.alwaysOpenTheLastTabWhenOpeningTheApp = false
+ settings.openHomepageAfterFourHoursOfInactivity = false
+
+ assertTrue(settings.shouldStartOnHome())
+ }
+
+ @Test
+ fun `GIVEN startOnHomeNever is selected WHEN calling shouldStartOnHome THEN return be false`() {
+ settings.alwaysOpenTheLastTabWhenOpeningTheApp = true
+ settings.alwaysOpenTheHomepageWhenOpeningTheApp = false
+ settings.openHomepageAfterFourHoursOfInactivity = false
+
+ assertFalse(settings.shouldStartOnHome())
+ }
+
+ @Test
+ fun `GIVEN startOnHomeAfterFourHours is selected after four hours of inactivity WHEN calling shouldStartOnHome THEN return true`() {
+ val localSetting = spyk(settings)
+ val now = Calendar.getInstance()
+
+ localSetting.openHomepageAfterFourHoursOfInactivity = true
+ localSetting.alwaysOpenTheLastTabWhenOpeningTheApp = false
+ localSetting.alwaysOpenTheHomepageWhenOpeningTheApp = false
+
+ now.timeInMillis = System.currentTimeMillis()
+ localSetting.lastBrowseActivity = now.timeInMillis
+ now.add(Calendar.HOUR, 4)
+
+ every { localSetting.timeNowInMillis() } returns now.timeInMillis
+
+ assertTrue(localSetting.shouldStartOnHome())
+ }
+
+ @Test
+ fun `GIVEN startOnHomeAfterFourHours is selected and with recent activity WHEN calling shouldStartOnHome THEN return false`() {
+ val localSetting = spyk(settings)
+ val now = System.currentTimeMillis()
+
+ localSetting.openHomepageAfterFourHoursOfInactivity = true
+ localSetting.alwaysOpenTheLastTabWhenOpeningTheApp = false
+ localSetting.alwaysOpenTheHomepageWhenOpeningTheApp = false
+
+ localSetting.lastBrowseActivity = now
+
+ every { localSetting.timeNowInMillis() } returns now
+
+ assertFalse(localSetting.shouldStartOnHome())
+ }
+
+ @Test
+ fun `GIVEN re-engagement notification shown and number of app launch THEN should set re-engagement notification returns correct value`() {
+ val localSetting = spyk(settings)
+
+ localSetting.reEngagementNotificationShown = false
+ localSetting.numberOfAppLaunches = 0
+ assert(localSetting.shouldSetReEngagementNotification())
+
+ localSetting.numberOfAppLaunches = 1
+ assert(localSetting.shouldSetReEngagementNotification())
+
+ localSetting.numberOfAppLaunches = 2
+ assertFalse(localSetting.shouldSetReEngagementNotification())
+
+ localSetting.reEngagementNotificationShown = true
+ localSetting.numberOfAppLaunches = 0
+ assertFalse(localSetting.shouldSetReEngagementNotification())
+ }
+
+ @Test
+ fun `GIVEN re-engagement notification shown and is default browser THEN should show re-engagement notification returns correct value`() {
+ val localSetting = spyk(settings)
+
+ every { localSetting.isDefaultBrowserBlocking() } returns false
+
+ localSetting.reEngagementNotificationShown = false
+ assert(localSetting.shouldShowReEngagementNotification())
+
+ localSetting.reEngagementNotificationShown = true
+ assertFalse(localSetting.shouldShowReEngagementNotification())
+
+ every { localSetting.isDefaultBrowserBlocking() } returns true
+
+ localSetting.reEngagementNotificationShown = false
+ assertFalse(localSetting.shouldShowReEngagementNotification())
+
+ localSetting.reEngagementNotificationShown = true
+ assertFalse(localSetting.shouldShowReEngagementNotification())
+ }
+
+ @Test
+ fun inactiveTabsAreEnabled() {
+ // When just created
+ // Then
+ assertTrue(settings.inactiveTabsAreEnabled)
+ }
+
+ @Test
+ fun `GIVEN shouldShowInactiveTabsAutoCloseDialog WHEN the dialog has been dismissed before THEN no show the dialog`() {
+ val settings = spyk(settings)
+ every { settings.hasInactiveTabsAutoCloseDialogBeenDismissed } returns true
+
+ assertFalse(settings.shouldShowInactiveTabsAutoCloseDialog(20))
+ }
+
+ @Test
+ fun `GIVEN shouldShowInactiveTabsAutoCloseDialog WHEN the inactive tabs are less than the minimum THEN no show the dialog`() {
+ assertFalse(settings.shouldShowInactiveTabsAutoCloseDialog(19))
+ }
+
+ @Test
+ fun `GIVEN shouldShowInactiveTabsAutoCloseDialog WHEN closeTabsAfterOneMonth is already selected THEN no show the dialog`() {
+ val settings = spyk(settings)
+ every { settings.closeTabsAfterOneMonth } returns true
+
+ assertFalse(settings.shouldShowInactiveTabsAutoCloseDialog(19))
+ }
+
+ @Test
+ fun `GIVEN shouldShowInactiveTabsAutoCloseDialog WHEN the dialog has not been dismissed, with more inactive tabs than the queried and closeTabsAfterOneMonth not set THEN show the dialog`() {
+ val settings = spyk(settings)
+ every { settings.closeTabsAfterOneMonth } returns false
+ every { settings.hasInactiveTabsAutoCloseDialogBeenDismissed } returns false
+
+ assertTrue(settings.shouldShowInactiveTabsAutoCloseDialog(20))
+ }
+
+ @Test
+ fun `GIVEN hasUserBeenOnboarded is false and isLauncherIntent is false THEN shouldShowOnboarding returns false`() {
+ val settings = spyk(settings)
+
+ val actual = settings.shouldShowOnboarding(
+ hasUserBeenOnboarded = false,
+ isLauncherIntent = false,
+ )
+
+ assertFalse(actual)
+ }
+
+ @Test
+ fun `GIVEN hasUserBeenOnboarded is true THEN shouldShowOnboarding returns false`() {
+ val settings = spyk(settings)
+
+ val actual = settings.shouldShowOnboarding(
+ hasUserBeenOnboarded = true,
+ isLauncherIntent = true,
+ )
+
+ assertFalse(actual)
+ }
+
+ @Test
+ fun `GIVEN hasUserBeenOnboarded is false and isLauncherIntent is true THEN shouldShowOnboarding returns true`() {
+ val settings = spyk(settings)
+
+ val actual = settings.shouldShowOnboarding(
+ hasUserBeenOnboarded = false,
+ isLauncherIntent = true,
+ )
+
+ assertTrue(actual)
+ }
+
+ @Test
+ fun `GIVEN toolbarPositionTop is false, touchExplorationIsEnabled is true THEN shouldDefaultToBottomToolbar returns false`() {
+ val settings = spyk(settings)
+ every { settings.toolbarPositionTop } returns true
+ every { settings.touchExplorationIsEnabled } returns true
+
+ assertEquals(false, settings.shouldDefaultToBottomToolbar())
+ }
+
+ @Test
+ fun `GIVEN Https-only mode is disabled THEN the engine mode is HttpsOnlyMode#DISABLED`() {
+ settings.shouldUseHttpsOnly = false
+
+ val result = settings.getHttpsOnlyMode()
+
+ assertEquals(DISABLED, result)
+ }
+
+ @Test
+ fun `GIVEN Https-only mode is enabled THEN the engine mode is HttpsOnlyMode#ENABLED`() {
+ settings.shouldUseHttpsOnly = true
+
+ val result = settings.getHttpsOnlyMode()
+
+ assertEquals(ENABLED, result)
+ }
+
+ @Test
+ fun `GIVEN Https-only mode is enabled for all tabs THEN the engine mode is HttpsOnlyMode#ENABLED`() {
+ settings.apply {
+ shouldUseHttpsOnly = true
+ shouldUseHttpsOnlyInAllTabs = true
+ }
+
+ val result = settings.getHttpsOnlyMode()
+
+ assertEquals(ENABLED, result)
+ }
+
+ @Test
+ fun `GIVEN Https-only mode is enabled for only private tabs THEN the engine mode is HttpsOnlyMode#ENABLED_PRIVATE_ONLY`() {
+ settings.apply {
+ shouldUseHttpsOnly = true
+ shouldUseHttpsOnlyInPrivateTabsOnly = true
+ }
+
+ val result = settings.getHttpsOnlyMode()
+
+ assertEquals(ENABLED_PRIVATE_ONLY, result)
+ }
+
+ @Test
+ fun `GIVEN unset user preferences THEN https-only is disabled`() {
+ assertFalse(settings.shouldUseHttpsOnly)
+ }
+
+ @Test
+ fun `GIVEN unset user preferences THEN https-only is enabled for all tabs`() {
+ assertTrue(settings.shouldUseHttpsOnlyInAllTabs)
+ }
+
+ @Test
+ fun `GIVEN unset user preferences THEN https-only is disabled for private tabs`() {
+ assertFalse(settings.shouldUseHttpsOnlyInPrivateTabsOnly)
+ }
+
+ @Test
+ fun `GIVEN open links in apps setting THEN return the correct display string`() {
+ settings.openLinksInExternalApp = "pref_key_open_links_in_apps_always"
+ settings.lastKnownMode = BrowsingMode.Normal
+ assertEquals(settings.getOpenLinksInAppsString(), "Always")
+
+ settings.openLinksInExternalApp = "pref_key_open_links_in_apps_ask"
+ assertEquals(settings.getOpenLinksInAppsString(), "Ask before opening")
+
+ settings.openLinksInExternalApp = "pref_key_open_links_in_apps_never"
+ assertEquals(settings.getOpenLinksInAppsString(), "Never")
+
+ settings.openLinksInExternalApp = "pref_key_open_links_in_apps_always"
+ settings.lastKnownMode = BrowsingMode.Private
+ assertEquals(settings.getOpenLinksInAppsString(), "Ask before opening")
+
+ settings.openLinksInExternalApp = "pref_key_open_links_in_apps_ask"
+ assertEquals(settings.getOpenLinksInAppsString(), "Ask before opening")
+
+ settings.openLinksInExternalApp = "pref_key_open_links_in_apps_never"
+ assertEquals(settings.getOpenLinksInAppsString(), "Never")
+ }
+
+ @Test
+ fun `GIVEN a written integer value for pref_key_search_widget_installed WHEN reading searchWidgetInstalled THEN do not throw a ClassCastException`() {
+ val expectedInt = 5
+ val oldPrefKey = "pref_key_search_widget_installed"
+
+ settings.preferences.edit().putInt(oldPrefKey, expectedInt).apply()
+
+ try {
+ assertEquals(expectedInt, settings.preferences.getInt(oldPrefKey, 0))
+ assertFalse(settings.searchWidgetInstalled)
+ } catch (e: ClassCastException) {
+ fail("Unexpected ClassCastException")
+ }
+ }
+
+ @Test
+ fun `GIVEN previously stored pref_key_search_widget_installed value WHEN calling migrateSearchWidgetInstalledIfNeeded THEN migrate the value`() {
+ val expectedInt = 5
+ val oldPrefKey = "pref_key_search_widget_installed"
+
+ settings.preferences.edit().putInt(oldPrefKey, expectedInt).apply()
+
+ assertEquals(expectedInt, settings.preferences.getInt(oldPrefKey, 0))
+ assertFalse(settings.searchWidgetInstalled)
+
+ settings.migrateSearchWidgetInstalledPrefIfNeeded()
+
+ assertTrue(settings.searchWidgetInstalled)
+ }
+
+ @Test
+ fun `GIVEN none previously stored pref_key_search_widget_installed value WHEN calling migrateSearchWidgetInstalledIfNeeded THEN migration should not happen`() {
+ val oldPrefKey = "pref_key_search_widget_installed"
+ val expectedDefaultValue = 0
+ val storedValue = settings.preferences.getInt(oldPrefKey, expectedDefaultValue)
+
+ assertEquals(expectedDefaultValue, storedValue)
+
+ settings.migrateSearchWidgetInstalledPrefIfNeeded()
+
+ assertEquals(expectedDefaultValue, settings.preferences.getInt(oldPrefKey, expectedDefaultValue))
+ assertFalse(settings.searchWidgetInstalled)
+ }
+
+ @Test
+ fun `GIVEN previously stored pref_key_search_widget_installed value is Boolean WHEN calling migrateSearchWidgetInstalledIfNeeded THEN crash should not happen`() {
+ val oldPrefKey = "pref_key_search_widget_installed"
+ settings.preferences.edit().putBoolean(oldPrefKey, false).apply()
+
+ settings.migrateSearchWidgetInstalledPrefIfNeeded()
+ assertFalse(settings.searchWidgetInstalled)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/utils/ToolbarPopupWindowTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/utils/ToolbarPopupWindowTest.kt
new file mode 100644
index 0000000000..bb3efa95c0
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/utils/ToolbarPopupWindowTest.kt
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.utils
+
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.ReaderState
+import mozilla.components.browser.state.state.createCustomTab
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class ToolbarPopupWindowTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Test
+ fun `getUrlForClipboard should get the right URL`() {
+ // Custom tab
+ val customTabSession = createCustomTab("https://mozilla.org")
+ var store = BrowserStore(BrowserState(customTabs = listOf(customTabSession)))
+ assertEquals(
+ "https://mozilla.org",
+ ToolbarPopupWindow.getUrlForClipboard(store, customTabSession.id),
+ )
+
+ // Regular tab
+ val regularTab = createTab(url = "http://firefox.com")
+ store = BrowserStore(BrowserState(tabs = listOf(regularTab), selectedTabId = regularTab.id))
+ assertEquals("http://firefox.com", ToolbarPopupWindow.getUrlForClipboard(store))
+
+ // Reader Tab
+ val readerTab = createTab(
+ url = "moz-extension://1234",
+ readerState = ReaderState(active = true, activeUrl = "https://blog.mozilla.org/123"),
+ )
+ store = BrowserStore(BrowserState(tabs = listOf(readerTab), selectedTabId = readerTab.id))
+ assertEquals("https://blog.mozilla.org/123", ToolbarPopupWindow.getUrlForClipboard(store))
+ }
+
+ @Test
+ fun `getUrlForClipboard should get the updated URL`() {
+ // Custom tab
+ val customTabSession = createCustomTab("https://mozilla.org")
+ var store = BrowserStore(BrowserState(customTabs = listOf(customTabSession)))
+ store.dispatch(ContentAction.UpdateUrlAction(customTabSession.id, "https://firefox.com")).joinBlocking()
+ assertEquals(
+ "https://firefox.com",
+ ToolbarPopupWindow.getUrlForClipboard(store, customTabSession.id),
+ )
+
+ // Regular tab
+ val regularTab = createTab(url = "http://firefox.com")
+ store = BrowserStore(BrowserState(tabs = listOf(regularTab), selectedTabId = regularTab.id))
+ store.dispatch(ContentAction.UpdateUrlAction(regularTab.id, "https://mozilla.org")).joinBlocking()
+ assertEquals("https://mozilla.org", ToolbarPopupWindow.getUrlForClipboard(store))
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/utils/view/GroupableRadioButtonTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/utils/view/GroupableRadioButtonTest.kt
new file mode 100644
index 0000000000..6f7e581afc
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/utils/view/GroupableRadioButtonTest.kt
@@ -0,0 +1,66 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.utils.view
+
+import io.mockk.Called
+import io.mockk.mockk
+import io.mockk.verify
+import io.mockk.verifySequence
+import org.junit.Test
+
+class GroupableRadioButtonTest {
+
+ @Test
+ fun `test add 1 radio to group`() {
+ val radio = mockk<GroupableRadioButton>(relaxed = true)
+ addToRadioGroup(radio)
+ verify { radio wasNot Called }
+ }
+
+ @Test
+ fun `test add 2 radios to group`() {
+ val radio1 = mockk<GroupableRadioButton>(relaxed = true)
+ val radio2 = mockk<GroupableRadioButton>(relaxed = true)
+ addToRadioGroup(radio1, radio2)
+
+ verifySequence {
+ radio1.addToRadioGroup(radio2)
+ radio2.addToRadioGroup(radio1)
+ }
+ }
+
+ @Test
+ fun `test add 3 radios to group`() {
+ val radio1 = mockk<GroupableRadioButton>(relaxed = true)
+ val radio2 = mockk<GroupableRadioButton>(relaxed = true)
+ val radio3 = mockk<GroupableRadioButton>(relaxed = true)
+ addToRadioGroup(radio1, radio2, radio3)
+
+ verifySequence {
+ radio1.addToRadioGroup(radio2)
+ radio2.addToRadioGroup(radio1)
+
+ radio1.addToRadioGroup(radio3)
+ radio3.addToRadioGroup(radio1)
+
+ radio2.addToRadioGroup(radio3)
+ radio3.addToRadioGroup(radio2)
+ }
+ }
+
+ @Test
+ fun `test uncheck all`() {
+ val radio1 = mockk<GroupableRadioButton>(relaxed = true)
+ val radio2 = mockk<GroupableRadioButton>(relaxed = true)
+ val radio3 = mockk<GroupableRadioButton>(relaxed = true)
+ listOf(radio1, radio2, radio3).uncheckAll()
+
+ verifySequence {
+ radio1.updateRadioValue(false)
+ radio2.updateRadioValue(false)
+ radio3.updateRadioValue(false)
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wallpapers/LegacyWallpaperMigrationTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wallpapers/LegacyWallpaperMigrationTest.kt
new file mode 100644
index 0000000000..72e1851e03
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wallpapers/LegacyWallpaperMigrationTest.kt
@@ -0,0 +1,240 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.wallpapers
+
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.mozilla.fenix.utils.Settings
+import org.mozilla.fenix.utils.toHexColor
+import org.mozilla.fenix.wallpapers.LegacyWallpaperMigration.Companion.TURNING_RED_MEI_WALLPAPER_NAME
+import org.mozilla.fenix.wallpapers.LegacyWallpaperMigration.Companion.TURNING_RED_PANDA_WALLPAPER_NAME
+import org.mozilla.fenix.wallpapers.LegacyWallpaperMigration.Companion.TURNING_RED_WALLPAPER_TEXT_COLOR
+import java.io.File
+
+class LegacyWallpaperMigrationTest {
+ @Rule
+ @JvmField
+ val tempFolder = TemporaryFolder()
+ private lateinit var settings: Settings
+ private lateinit var wallpapersFolder: File
+ private lateinit var downloadWallpaper: (Wallpaper) -> Wallpaper.ImageFileState
+ private lateinit var migrationHelper: LegacyWallpaperMigration
+ private lateinit var portraitLightFolder: File
+ private lateinit var portraitDarkFolder: File
+ private lateinit var landscapeLightFolder: File
+ private lateinit var landscapeDarkFolder: File
+
+ @Before
+ fun setup() {
+ wallpapersFolder = File(tempFolder.root, "wallpapers")
+ settings = mockk(relaxed = true)
+ downloadWallpaper = mockk(relaxed = true)
+ migrationHelper = LegacyWallpaperMigration(
+ storageRootDirectory = tempFolder.root,
+ settings = settings,
+ downloadWallpaper,
+ )
+ }
+
+ @Test
+ fun `WHEN the legacy wallpaper is migrated THEN the legacy wallpapers are deleted`() = runTest {
+ val wallpaperName = "wallpaper1"
+
+ createAllLegacyFiles(wallpaperName)
+
+ migrationHelper.migrateLegacyWallpaper(wallpaperName)
+
+ assertTrue(getAllFiles(wallpaperName).all { it.exists() })
+ assertFalse(File(portraitLightFolder, "$wallpaperName.png").exists())
+ assertFalse(File(portraitDarkFolder, "$wallpaperName.png").exists())
+ assertFalse(File(landscapeLightFolder, "$wallpaperName.png").exists())
+ assertFalse(File(landscapeDarkFolder, "$wallpaperName.png").exists())
+ }
+
+ @Test
+ fun `GIVEN landscape legacy wallpaper is missing WHEN the wallpapers are migrated THEN the wallpaper is not migrated`() =
+ runTest {
+ val portraitOnlyWallpaperName = "portraitOnly"
+ val completeWallpaperName = "legacy"
+ createAllLegacyFiles(completeWallpaperName)
+ File(landscapeLightFolder, "$portraitOnlyWallpaperName.png").apply {
+ createNewFile()
+ }
+ File(landscapeDarkFolder, "$portraitOnlyWallpaperName.png").apply {
+ createNewFile()
+ }
+
+ migrationHelper.migrateLegacyWallpaper(portraitOnlyWallpaperName)
+ migrationHelper.migrateLegacyWallpaper(completeWallpaperName)
+
+ assertTrue(getAllFiles(completeWallpaperName).all { it.exists() })
+ assertFalse(getAllFiles(portraitOnlyWallpaperName).any { it.exists() })
+ }
+
+ @Test
+ fun `GIVEN portrait legacy wallpaper is missing WHEN the wallpapers are migrated THEN the wallpaper is not migrated`() =
+ runTest {
+ val landscapeOnlyWallpaperName = "portraitOnly"
+ val completeWallpaperName = "legacy"
+ createAllLegacyFiles(completeWallpaperName)
+ File(portraitLightFolder, "$landscapeOnlyWallpaperName.png").apply {
+ createNewFile()
+ }
+ File(portraitDarkFolder, "$landscapeOnlyWallpaperName.png").apply {
+ createNewFile()
+ }
+
+ migrationHelper.migrateLegacyWallpaper(landscapeOnlyWallpaperName)
+ migrationHelper.migrateLegacyWallpaper(completeWallpaperName)
+
+ assertTrue(getAllFiles(completeWallpaperName).all { it.exists() })
+ assertFalse(getAllFiles(landscapeOnlyWallpaperName).any { it.exists() })
+ }
+
+ @Test
+ fun `GIVEN a Turning Red wallpaper WHEN it is successfully migrated THEN set a matching text color`() {
+ runTest {
+ createAllLegacyFiles(TURNING_RED_MEI_WALLPAPER_NAME)
+ migrationHelper.migrateLegacyWallpaper(TURNING_RED_MEI_WALLPAPER_NAME)
+ assertTrue(getAllFiles(TURNING_RED_MEI_WALLPAPER_NAME).all { it.exists() })
+ verify(exactly = 1) {
+ settings.currentWallpaperTextColor = TURNING_RED_WALLPAPER_TEXT_COLOR.toHexColor()
+ }
+
+ createAllLegacyFiles(TURNING_RED_PANDA_WALLPAPER_NAME)
+ migrationHelper.migrateLegacyWallpaper(TURNING_RED_PANDA_WALLPAPER_NAME)
+ assertTrue(getAllFiles(TURNING_RED_PANDA_WALLPAPER_NAME).all { it.exists() })
+ verify(exactly = 2) {
+ settings.currentWallpaperTextColor = TURNING_RED_WALLPAPER_TEXT_COLOR.toHexColor()
+ }
+ }
+ }
+
+ @Test
+ fun `GIVEN a Turning Red wallpaper WHEN it can't be migrated THEN don't set a matching text color`() {
+ runTest {
+ migrationHelper.migrateLegacyWallpaper(TURNING_RED_MEI_WALLPAPER_NAME)
+ migrationHelper.migrateLegacyWallpaper(TURNING_RED_PANDA_WALLPAPER_NAME)
+
+ assertFalse(getAllFiles(TURNING_RED_MEI_WALLPAPER_NAME).all { it.exists() })
+ assertFalse(getAllFiles(TURNING_RED_PANDA_WALLPAPER_NAME).all { it.exists() })
+ verify(exactly = 0) {
+ settings.currentWallpaperTextColor = TURNING_RED_WALLPAPER_TEXT_COLOR.toHexColor()
+ }
+ }
+ }
+
+ @Test
+ fun `GIVEN legacy wallpapers different than Turning Red WHEN they are tried to be migrated THEN don't set a matching text color`() {
+ runTest {
+ val wallpaper1 = "wallpaper1"
+ val wallpaper2 = "wallpaper2"
+
+ migrationHelper.migrateLegacyWallpaper(wallpaper1)
+ assertFalse(getAllFiles(wallpaper1).all { it.exists() })
+ verify(exactly = 0) {
+ settings.currentWallpaperTextColor = TURNING_RED_WALLPAPER_TEXT_COLOR.toHexColor()
+ }
+
+ createAllLegacyFiles(wallpaper2)
+ migrationHelper.migrateLegacyWallpaper(wallpaper2)
+ assertTrue(getAllFiles(wallpaper2).all { it.exists() })
+ verify(exactly = 0) {
+ settings.currentWallpaperTextColor = TURNING_RED_WALLPAPER_TEXT_COLOR.toHexColor()
+ }
+ }
+ }
+
+ @Test
+ fun `WHEN the beach-vibe legacy wallpaper is migrated THEN the legacy wallpapers destination is beach-vibes`() = runTest {
+ val wallpaperName = Wallpaper.beachVibeName
+
+ createAllLegacyFiles(wallpaperName)
+
+ val migratedWallpaperName = migrationHelper.migrateLegacyWallpaper(wallpaperName)
+
+ assertEquals("beach-vibes", migratedWallpaperName)
+ assertTrue(getAllFiles("beach-vibes").all { it.exists() })
+ }
+
+ @Test
+ fun `WHEN a drawable legacy wallpaper is migrated THEN the respective V2 wallpaper is downloaded`() = runTest {
+ var migratedWallpaperName = migrationHelper.migrateLegacyWallpaper(Wallpaper.ceruleanName)
+
+ assertEquals(Wallpaper.ceruleanName, migratedWallpaperName)
+ verify {
+ downloadWallpaper(
+ withArg {
+ assertEquals(Wallpaper.ceruleanName, it.name)
+ assertEquals(Wallpaper.ClassicFirefoxCollection, it.collection)
+ },
+ )
+ }
+
+ migratedWallpaperName = migrationHelper.migrateLegacyWallpaper(Wallpaper.sunriseName)
+
+ assertEquals(Wallpaper.sunriseName, migratedWallpaperName)
+ verify {
+ downloadWallpaper(
+ withArg {
+ assertEquals(Wallpaper.sunriseName, it.name)
+ assertEquals(Wallpaper.ClassicFirefoxCollection, it.collection)
+ },
+ )
+ }
+
+ migratedWallpaperName = migrationHelper.migrateLegacyWallpaper(Wallpaper.amethystName)
+
+ assertEquals(Wallpaper.amethystName, migratedWallpaperName)
+ verify {
+ downloadWallpaper(
+ withArg {
+ assertEquals(Wallpaper.amethystName, it.name)
+ assertEquals(Wallpaper.ClassicFirefoxCollection, it.collection)
+ },
+ )
+ }
+ }
+
+ private fun createAllLegacyFiles(name: String) {
+ if (!this::portraitLightFolder.isInitialized || !portraitLightFolder.exists()) {
+ portraitLightFolder = tempFolder.newFolder("wallpapers", "portrait", "light")
+ portraitDarkFolder = tempFolder.newFolder("wallpapers", "portrait", "dark")
+ landscapeLightFolder = tempFolder.newFolder("wallpapers", "landscape", "light")
+ landscapeDarkFolder = tempFolder.newFolder("wallpapers", "landscape", "dark")
+ }
+
+ File(portraitLightFolder, "$name.png").apply {
+ createNewFile()
+ }
+ File(landscapeLightFolder, "$name.png").apply {
+ createNewFile()
+ }
+ File(portraitDarkFolder, "$name.png").apply {
+ createNewFile()
+ }
+ File(landscapeDarkFolder, "$name.png").apply {
+ createNewFile()
+ }
+ }
+
+ private fun getAllFiles(name: String): List<File> {
+ val folder = File(wallpapersFolder, name)
+ return listOf(
+ folder,
+ File(folder, "portrait.png"),
+ File(folder, "landscape.png"),
+ File(folder, "thumbnail.png"),
+ )
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wallpapers/WallpaperDownloaderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wallpapers/WallpaperDownloaderTest.kt
new file mode 100644
index 0000000000..2736ca0627
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wallpapers/WallpaperDownloaderTest.kt
@@ -0,0 +1,146 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.wallpapers
+
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import mozilla.components.concept.fetch.Client
+import mozilla.components.concept.fetch.Request
+import mozilla.components.concept.fetch.Response
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.mozilla.fenix.BuildConfig
+import java.io.File
+import java.lang.IllegalStateException
+
+class WallpaperDownloaderTest {
+ @Rule
+ @JvmField
+ val tempFolder = TemporaryFolder()
+
+ private val remoteHost = BuildConfig.WALLPAPER_URL
+
+ private val wallpaperBytes = "file contents"
+ private val portraitResponseBodySuccess = Response.Body(wallpaperBytes.byteInputStream())
+ private val landscapeResponseBodySuccess = Response.Body(wallpaperBytes.byteInputStream())
+ private val mockPortraitResponse = mockk<Response>()
+ private val mockLandscapeResponse = mockk<Response>()
+ private val mockClient = mockk<Client>()
+
+ private val dispatcher = UnconfinedTestDispatcher()
+
+ private val wallpaperCollection = Wallpaper.Collection(
+ name = "collection",
+ heading = null,
+ description = null,
+ learnMoreUrl = null,
+ availableLocales = null,
+ startDate = null,
+ endDate = null,
+ )
+
+ private lateinit var downloader: WallpaperDownloader
+
+ @Before
+ fun setup() {
+ downloader = WallpaperDownloader(tempFolder.root, mockClient, dispatcher)
+ }
+
+ @Test
+ fun `GIVEN that asset request is successful WHEN downloading assets THEN both files are created in expected location`() = runTest {
+ val wallpaper = generateWallpaper()
+ val portraitRequest = wallpaper.generateRequest("portrait")
+ val landscapeRequest = wallpaper.generateRequest("landscape")
+ every { mockPortraitResponse.status } returns 200
+ every { mockLandscapeResponse.status } returns 200
+ every { mockPortraitResponse.body } returns portraitResponseBodySuccess
+ every { mockLandscapeResponse.body } returns landscapeResponseBodySuccess
+ every { mockClient.fetch(portraitRequest) } returns mockPortraitResponse
+ every { mockClient.fetch(landscapeRequest) } returns mockLandscapeResponse
+
+ downloader.downloadWallpaper(wallpaper)
+
+ val expectedPortraitFile = File(tempFolder.root, "wallpapers/${wallpaper.name}/portrait.png")
+ val expectedLandscapeFile = File(tempFolder.root, "wallpapers/${wallpaper.name}/landscape.png")
+ assertTrue(expectedPortraitFile.exists() && expectedPortraitFile.readText() == wallpaperBytes)
+ assertTrue(expectedLandscapeFile.exists() && expectedLandscapeFile.readText() == wallpaperBytes)
+ }
+
+ @Test
+ fun `GIVEN that thumbnail request is successful WHEN downloading THEN file is created in expected location`() = runTest {
+ val wallpaper = generateWallpaper()
+ val thumbnailRequest = wallpaper.generateRequest("thumbnail")
+ val mockThumbnailResponse = mockk<Response>()
+ every { mockThumbnailResponse.status } returns 200
+ every { mockThumbnailResponse.body } returns Response.Body(wallpaperBytes.byteInputStream())
+ every { mockClient.fetch(thumbnailRequest) } returns mockThumbnailResponse
+
+ val result = downloader.downloadThumbnail(wallpaper)
+
+ val expectedThumbnailFile = File(tempFolder.root, "wallpapers/${wallpaper.name}/thumbnail.png")
+ assertTrue(expectedThumbnailFile.exists() && expectedThumbnailFile.readText() == wallpaperBytes)
+ assertEquals(Wallpaper.ImageFileState.Downloaded, result)
+ }
+
+ @Test
+ fun `GIVEN that request fails WHEN downloading THEN file is not created`() = runTest {
+ val wallpaper = generateWallpaper()
+ val portraitRequest = wallpaper.generateRequest("portrait")
+ val landscapeRequest = wallpaper.generateRequest("landscape")
+ every { mockPortraitResponse.status } returns 400
+ every { mockLandscapeResponse.status } returns 400
+ every { mockClient.fetch(portraitRequest) } returns mockPortraitResponse
+ every { mockClient.fetch(landscapeRequest) } returns mockLandscapeResponse
+
+ downloader.downloadWallpaper(wallpaper)
+
+ val expectedPortraitFile = File(tempFolder.root, "wallpapers/${wallpaper.name}/portrait.png")
+ val expectedLandscapeFile = File(tempFolder.root, "wallpapers/${wallpaper.name}/landscape.png")
+ assertFalse(expectedPortraitFile.exists())
+ assertFalse(expectedLandscapeFile.exists())
+ }
+
+ @Test
+ fun `GIVEN that copying the file fails WHEN downloading THEN file is not created`() = runTest {
+ val wallpaper = generateWallpaper()
+ val portraitRequest = wallpaper.generateRequest("portrait")
+ val landscapeRequest = wallpaper.generateRequest("landscape")
+ every { mockPortraitResponse.status } returns 200
+ every { mockLandscapeResponse.status } returns 200
+ every { mockPortraitResponse.body } throws IllegalStateException()
+ every { mockClient.fetch(portraitRequest) } throws IllegalStateException()
+ every { mockClient.fetch(landscapeRequest) } returns mockLandscapeResponse
+
+ downloader.downloadWallpaper(wallpaper)
+
+ val expectedPortraitFile = File(tempFolder.root, "wallpapers/${wallpaper.name}/portrait.png")
+ val expectedLandscapeFile = File(tempFolder.root, "wallpapers/${wallpaper.name}/landscape.png")
+ assertFalse(expectedPortraitFile.exists())
+ assertFalse(expectedLandscapeFile.exists())
+ }
+
+ private fun generateWallpaper(name: String = "name") = Wallpaper(
+ name = name,
+ collection = wallpaperCollection,
+ textColor = null,
+ cardColorLight = null,
+ cardColorDark = null,
+ thumbnailFileState = Wallpaper.ImageFileState.Unavailable,
+ assetsFileState = Wallpaper.ImageFileState.Unavailable,
+ )
+
+ private fun Wallpaper.generateRequest(type: String) = Request(
+ url = "$remoteHost/${collection.name}/$name/$type.png",
+ method = Request.Method.GET,
+ conservative = true,
+ )
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wallpapers/WallpaperFileManagerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wallpapers/WallpaperFileManagerTest.kt
new file mode 100644
index 0000000000..1fbbf2b984
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wallpapers/WallpaperFileManagerTest.kt
@@ -0,0 +1,178 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.wallpapers
+
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.mozilla.fenix.utils.Settings
+import java.io.File
+
+class WallpaperFileManagerTest {
+ @Rule
+ @JvmField
+ val tempFolder = TemporaryFolder()
+ private lateinit var wallpapersFolder: File
+
+ private val dispatcher = UnconfinedTestDispatcher()
+
+ private lateinit var fileManager: WallpaperFileManager
+
+ private lateinit var settings: Settings
+
+ @Before
+ fun setup() {
+ wallpapersFolder = File(tempFolder.root, "wallpapers")
+ fileManager = WallpaperFileManager(
+ storageRootDirectory = tempFolder.root,
+ coroutineDispatcher = dispatcher,
+ )
+ settings = mockk {
+ every { currentWallpaperName } returns wallpaperName
+ every { currentWallpaperTextColor } returns 0L
+ every { currentWallpaperCardColorLight } returns 0L
+ every { currentWallpaperCardColorDark } returns 0L
+ }
+ }
+
+ @Test
+ fun `GIVEN wallpaper directory exists WHEN looked up THEN wallpaper created with correct name`() = runTest {
+ createAllFiles(wallpaperName)
+
+ val result = fileManager.lookupExpiredWallpaper(settings)
+
+ val expected = generateWallpaper(name = wallpaperName)
+ assertEquals(expected, result)
+ }
+
+ @Test
+ fun `GIVEN portrait file missing in directories WHEN expired wallpaper looked up THEN null returned`() = runTest {
+ File(wallpapersFolder, "$wallpaperName/landscape.png").apply {
+ mkdirs()
+ createNewFile()
+ }
+ File(wallpapersFolder, "$wallpaperName/thumbnail.png").apply {
+ mkdirs()
+ createNewFile()
+ }
+
+ val result = fileManager.lookupExpiredWallpaper(settings)
+
+ assertEquals(null, result)
+ }
+
+ @Test
+ fun `GIVEN landscape file missing in directories WHEN expired wallpaper looked up THEN null returned`() = runTest {
+ File(wallpapersFolder, "$wallpaperName/portrait.png").apply {
+ mkdirs()
+ createNewFile()
+ }
+ File(wallpapersFolder, "$wallpaperName/thumbnail.png").apply {
+ mkdirs()
+ createNewFile()
+ }
+
+ val result = fileManager.lookupExpiredWallpaper(settings)
+
+ assertEquals(null, result)
+ }
+
+ @Test
+ fun `GIVEN thumbnail file missing in directories WHEN expired wallpaper looked up THEN null returned`() = runTest {
+ File(wallpapersFolder, "$wallpaperName/portrait.png").apply {
+ mkdirs()
+ createNewFile()
+ }
+ File(wallpapersFolder, "$wallpaperName/landscape.png").apply {
+ mkdirs()
+ createNewFile()
+ }
+
+ val result = fileManager.lookupExpiredWallpaper(settings)
+
+ assertEquals(null, result)
+ }
+
+ @Test
+ fun `WHEN cleaned THEN current wallpaper and available wallpapers kept`() = runTest {
+ val currentName = "current"
+ val currentWallpaper = generateWallpaper(name = currentName)
+ val availableName = "available"
+ val available = generateWallpaper(name = availableName)
+ val unavailableName = "unavailable"
+ createAllFiles(currentName)
+ createAllFiles(availableName)
+ createAllFiles(unavailableName)
+
+ fileManager.clean(currentWallpaper, listOf(available))
+
+ assertTrue(getAllFiles(currentName).all { it.exists() })
+ assertTrue(getAllFiles(availableName).all { it.exists() })
+ assertTrue(getAllFiles(unavailableName).none { it.exists() })
+ }
+
+ @Test
+ fun `WHEN both wallpaper assets exist THEN the file lookup will succeed`() = runTest {
+ val wallpaper = generateWallpaper("name")
+ createAllFiles(wallpaper.name)
+
+ val result = fileManager.wallpaperImagesExist(wallpaper)
+
+ assertTrue(result)
+ }
+
+ @Test
+ fun `WHEN at least one wallpaper asset does not exist THEN the file lookup will fail`() = runTest {
+ val wallpaper = generateWallpaper("name")
+ val allFiles = getAllFiles(wallpaper.name)
+ (0 until (allFiles.size - 1)).forEach {
+ allFiles[it].mkdirs()
+ allFiles[it].createNewFile()
+ }
+
+ val result = fileManager.wallpaperImagesExist(wallpaper)
+
+ assertFalse(result)
+ }
+
+ private fun createAllFiles(name: String) {
+ for (file in getAllFiles(name)) {
+ file.mkdirs()
+ file.createNewFile()
+ }
+ }
+
+ private fun getAllFiles(name: String): List<File> {
+ val folder = File(wallpapersFolder, name)
+ return listOf(
+ folder,
+ File(folder, "portrait.png"),
+ File(folder, "landscape.png"),
+ File(folder, "thumbnail.png"),
+ )
+ }
+
+ private fun generateWallpaper(name: String) = Wallpaper(
+ name = name,
+ textColor = 0L,
+ cardColorLight = 0L,
+ cardColorDark = 0L,
+ thumbnailFileState = Wallpaper.ImageFileState.Downloaded,
+ assetsFileState = Wallpaper.ImageFileState.Downloaded,
+ collection = Wallpaper.DefaultCollection,
+ )
+
+ private companion object {
+ const val wallpaperName = "name"
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wallpapers/WallpaperMetadataFetcherTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wallpapers/WallpaperMetadataFetcherTest.kt
new file mode 100644
index 0000000000..35fcd694e3
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wallpapers/WallpaperMetadataFetcherTest.kt
@@ -0,0 +1,414 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.wallpapers
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import mozilla.components.concept.fetch.Client
+import mozilla.components.concept.fetch.Request
+import mozilla.components.concept.fetch.Response
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.BuildConfig
+import org.mozilla.fenix.wallpapers.WallpaperMetadataFetcher.Companion.currentJsonVersion
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Locale
+
+@RunWith(AndroidJUnit4::class)
+class WallpaperMetadataFetcherTest {
+
+ private val expectedRequest = Request(
+ url = BuildConfig.WALLPAPER_URL.substringBefore("android") +
+ "metadata/v$currentJsonVersion/wallpapers.json",
+ method = Request.Method.GET,
+ conservative = true,
+ )
+ private val mockResponse = mockk<Response>()
+ private val mockClient = mockk<Client> {
+ every { fetch(expectedRequest) } returns mockResponse
+ }
+
+ private lateinit var metadataFetcher: WallpaperMetadataFetcher
+
+ @Before
+ fun setup() {
+ metadataFetcher = WallpaperMetadataFetcher(mockClient)
+ }
+
+ @Test
+ fun `GIVEN wallpaper metadata WHEN parsed THEN wallpapers have correct ids, text and card colors`() = runTest {
+ val json = """
+ {
+ "last-updated-date": "2022-01-01",
+ "collections": [
+ {
+ "id": "classic-firefox",
+ "available-locales": null,
+ "availability-range": null,
+ "wallpapers": [
+ {
+ "id": "beach-vibes",
+ "text-color": "FBFBFE",
+ "card-color-light": "FFFFFF",
+ "card-color-dark": "000000"
+ },
+ {
+ "id": "sunrise",
+ "text-color": "15141A",
+ "card-color-light": "FFFFFF",
+ "card-color-dark": "000000"
+ }
+ ]
+ }
+ ]
+ }
+ """.trimIndent()
+ every { mockResponse.body } returns Response.Body(json.byteInputStream())
+
+ val wallpapers = metadataFetcher.downloadWallpaperList()
+
+ with(wallpapers[0]) {
+ assertEquals(0xFFFBFBFE, textColor)
+ assertEquals(0xFFFFFFFF, cardColorLight)
+ assertEquals(0xFF000000, cardColorDark)
+ }
+ with(wallpapers[1]) {
+ assertEquals(0xFF15141A, textColor)
+ assertEquals(0xFFFFFFFF, cardColorLight)
+ assertEquals(0xFF000000, cardColorDark)
+ }
+ }
+
+ @Test
+ fun `GIVEN wallpaper metadata is missing an id WHEN parsed THEN parsing fails`() = runTest {
+ val json = """
+ {
+ "last-updated-date": "2022-01-01",
+ "collections": [
+ {
+ "id": "classic-firefox",
+ "available-locales": null,
+ "availability-range": null,
+ "wallpapers": [
+ {
+ "text-color": "FBFBFE",
+ "card-color-light": "FFFFFF",
+ "card-color-dark": "000000"
+ },
+ {
+ "id": "sunrise",
+ "text-color": "15141A",
+ "card-color-light": "FFFFFF",
+ "card-color-dark": "000000"
+ }
+ ]
+ }
+ ]
+ }
+ """.trimIndent()
+ every { mockResponse.body } returns Response.Body(json.byteInputStream())
+
+ val wallpapers = metadataFetcher.downloadWallpaperList()
+
+ assertTrue(wallpapers.isEmpty())
+ }
+
+ @Test
+ fun `GIVEN wallpaper metadata is missing a text color WHEN parsed THEN parsing fails`() = runTest {
+ val json = """
+ {
+ "last-updated-date": "2022-01-01",
+ "collections": [
+ {
+ "id": "classic-firefox",
+ "available-locales": null,
+ "availability-range": null,
+ "wallpapers": [
+ {
+ "id": "beach-vibes",
+ "card-color-light": "FFFFFF",
+ "card-color-dark": "000000"
+ },
+ {
+ "id": "sunrise",
+ "text-color": "15141A",
+ "card-color-light": "FFFFFF",
+ "card-color-dark": "000000"
+ }
+ ]
+ }
+ ]
+ }
+ """.trimIndent()
+ every { mockResponse.body } returns Response.Body(json.byteInputStream())
+
+ val wallpapers = metadataFetcher.downloadWallpaperList()
+
+ assertTrue(wallpapers.isEmpty())
+ }
+
+ @Test
+ fun `GIVEN wallpaper metadata is missing a card color WHEN parsed THEN parsing fails`() = runTest {
+ val json = """
+ {
+ "last-updated-date": "2022-01-01",
+ "collections": [
+ {
+ "id": "classic-firefox",
+ "available-locales": null,
+ "availability-range": null,
+ "wallpapers": [
+ {
+ "id": "beach-vibes",
+ "text-color": "FBFBFE",
+ },
+ {
+ "id": "sunrise",
+ "text-color": "15141A",
+ "card-color-light": "FFFFFF",
+ "card-color-dark": "000000"
+ }
+ ]
+ }
+ ]
+ }
+ """.trimIndent()
+ every { mockResponse.body } returns Response.Body(json.byteInputStream())
+
+ val wallpapers = metadataFetcher.downloadWallpaperList()
+
+ assertTrue(wallpapers.isEmpty())
+ }
+
+ @Test
+ fun `GIVEN collection with specified locales WHEN parsed THEN wallpapers includes locales`() = runTest {
+ val locales = listOf("en-US", "es-US", "en-CA", "fr-CA")
+ val json = """
+ {
+ "last-updated-date": "2022-01-01",
+ "collections": [
+ {
+ "id": "classic-firefox",
+ "available-locales": ["en-US", "es-US", "en-CA", "fr-CA"],
+ "availability-range": null,
+ "wallpapers": [
+ {
+ "id": "beach-vibes",
+ "text-color": "FBFBFE",
+ "card-color-light": "FFFFFF",
+ "card-color-dark": "000000"
+ },
+ {
+ "id": "sunrise",
+ "text-color": "15141A",
+ "card-color-light": "FFFFFF",
+ "card-color-dark": "000000"
+ }
+ ]
+ }
+ ]
+ }
+ """.trimIndent()
+ every { mockResponse.body } returns Response.Body(json.byteInputStream())
+
+ val wallpapers = metadataFetcher.downloadWallpaperList()
+
+ assertTrue(wallpapers.isNotEmpty())
+ assertTrue(
+ wallpapers.all {
+ it.collection.availableLocales == locales
+ },
+ )
+ }
+
+ @Test
+ fun `GIVEN collection with specified date range WHEN parsed THEN wallpapers includes dates`() = runTest {
+ val calendar = Calendar.getInstance()
+ val startDate = calendar.run {
+ set(2022, Calendar.JUNE, 27)
+ time
+ }
+ val endDate = calendar.run {
+ set(2022, Calendar.SEPTEMBER, 30)
+ time
+ }
+ val json = """
+ {
+ "last-updated-date": "2022-01-01",
+ "collections": [
+ {
+ "id": "classic-firefox",
+ "available-locales": null,
+ "availability-range": {
+ "start": "2022-06-27",
+ "end": "2022-09-30"
+ },
+ "wallpapers": [
+ {
+ "id": "beach-vibes",
+ "text-color": "FBFBFE",
+ "card-color-light": "FFFFFF",
+ "card-color-dark": "000000"
+ },
+ {
+ "id": "sunrise",
+ "text-color": "15141A",
+ "card-color-light": "FFFFFF",
+ "card-color-dark": "000000"
+ }
+ ]
+ }
+ ]
+ }
+ """.trimIndent()
+ every { mockResponse.body } returns Response.Body(json.byteInputStream())
+
+ val wallpapers = metadataFetcher.downloadWallpaperList()
+
+ assertTrue(wallpapers.isNotEmpty())
+ assertTrue(
+ wallpapers.all {
+ val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.US)
+ formatter.format(startDate) == formatter.format(it.collection.startDate!!) &&
+ formatter.format(endDate) == formatter.format(it.collection.endDate!!)
+ },
+ )
+ }
+
+ @Test
+ fun `GIVEN collection with specified learn more url WHEN parsed THEN wallpapers includes url`() = runTest {
+ val json = """
+ {
+ "last-updated-date": "2022-01-01",
+ "collections": [
+ {
+ "id": "classic-firefox",
+ "available-locales": null,
+ "availability-range": null,
+ "learn-more-url": "https://www.mozilla.org",
+ "wallpapers": [
+ {
+ "id": "beach-vibes",
+ "text-color": "FBFBFE",
+ "card-color-light": "FFFFFF",
+ "card-color-dark": "000000"
+ },
+ {
+ "id": "sunrise",
+ "text-color": "15141A",
+ "card-color-light": "FFFFFF",
+ "card-color-dark": "000000"
+ }
+ ]
+ }
+ ]
+ }
+ """.trimIndent()
+ every { mockResponse.body } returns Response.Body(json.byteInputStream())
+
+ val wallpapers = metadataFetcher.downloadWallpaperList()
+
+ assertTrue(wallpapers.isNotEmpty())
+ assertTrue(
+ wallpapers.all {
+ it.collection.learnMoreUrl == "https://www.mozilla.org"
+ },
+ )
+ }
+
+ @Test
+ fun `GIVEN collection with specified heading and description WHEN parsed THEN wallpapers include them`() = runTest {
+ val heading = "A classic firefox experience"
+ val description = "Check out these cool foxes, they're adorable and can be your wallpaper"
+ val json = """
+ {
+ "last-updated-date": "2022-01-01",
+ "collections": [
+ {
+ "id": "classic-firefox",
+ "heading": "$heading",
+ "description": "$description",
+ "available-locales": null,
+ "availability-range": null,
+ "learn-more-url": null,
+ "wallpapers": [
+ {
+ "id": "beach-vibes",
+ "text-color": "FBFBFE",
+ "card-color-light": "FFFFFF",
+ "card-color-dark": "000000"
+ },
+ {
+ "id": "sunrise",
+ "text-color": "15141A",
+ "card-color-light": "FFFFFF",
+ "card-color-dark": "000000"
+ }
+ ]
+ }
+ ]
+ }
+ """.trimIndent()
+ every { mockResponse.body } returns Response.Body(json.byteInputStream())
+
+ val wallpapers = metadataFetcher.downloadWallpaperList()
+
+ assertTrue(wallpapers.isNotEmpty())
+ assertTrue(
+ wallpapers.all {
+ it.collection.heading == heading && it.collection.description == description
+ },
+ )
+ }
+
+ @Test
+ fun `GIVEN string fields with null values WHEN parsed THEN fields are correctly null`() = runTest {
+ val json = """
+ {
+ "last-updated-date": "2022-01-01",
+ "collections": [
+ {
+ "id": "classic-firefox",
+ "heading": null,
+ "description": null,
+ "available-locales": null,
+ "availability-range": null,
+ "learn-more-url": null,
+ "wallpapers": [
+ {
+ "id": "beach-vibes",
+ "text-color": "FBFBFE",
+ "card-color-light": "FFFFFF",
+ "card-color-dark": "000000"
+ },
+ {
+ "id": "sunrise",
+ "text-color": "15141A",
+ "card-color-light": "FFFFFF",
+ "card-color-dark": "000000"
+ }
+ ]
+ }
+ ]
+ }
+ """.trimIndent()
+ every { mockResponse.body } returns Response.Body(json.byteInputStream())
+
+ val wallpapers = metadataFetcher.downloadWallpaperList()
+
+ assertTrue(wallpapers.isNotEmpty())
+ assertTrue(
+ wallpapers.all {
+ it.collection.heading == null && it.collection.description == null
+ },
+ )
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wallpapers/WallpaperTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wallpapers/WallpaperTest.kt
new file mode 100644
index 0000000000..3515673528
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wallpapers/WallpaperTest.kt
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.wallpapers
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class WallpaperTest {
+ @Test
+ fun `GIVEN blank wallpaper name WHEN checking whether is default THEN is default`() {
+ val result = Wallpaper.nameIsDefault("")
+
+ assertTrue(result)
+ }
+
+ @Test
+ fun `GIVEN the default wallpaper is set to be shown WHEN checking whether the current wallpaper should be default THEN return true`() {
+ val result = Wallpaper.nameIsDefault("default")
+
+ assertTrue(result)
+ }
+
+ @Test
+ fun `GIVEN a custom wallpaper is set to be shown WHEN checking whether the current wallpaper should be default THEN return false`() {
+ val result = Wallpaper.nameIsDefault("wally world")
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `GIVEN the legacy wallpaper default name none WHEN checking whether the current wallpaper should be default THEN return true`() {
+ val result = Wallpaper.nameIsDefault("NONE")
+
+ assertTrue(result)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wallpapers/WallpapersUseCasesTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wallpapers/WallpapersUseCasesTest.kt
new file mode 100644
index 0000000000..858f2c2dbe
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wallpapers/WallpapersUseCasesTest.kt
@@ -0,0 +1,588 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.wallpapers
+
+import android.content.res.Configuration
+import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.runs
+import io.mockk.slot
+import io.mockk.spyk
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mozilla.fenix.components.AppStore
+import org.mozilla.fenix.components.appstate.AppAction
+import org.mozilla.fenix.utils.Settings
+import org.mozilla.fenix.utils.toHexColor
+import org.mozilla.fenix.wallpapers.LegacyWallpaperMigration.Companion.TURNING_RED_PANDA_WALLPAPER_CARD_COLOR_DARK
+import org.mozilla.fenix.wallpapers.LegacyWallpaperMigration.Companion.TURNING_RED_PANDA_WALLPAPER_CARD_COLOR_LIGHT
+import org.mozilla.fenix.wallpapers.LegacyWallpaperMigration.Companion.TURNING_RED_PANDA_WALLPAPER_NAME
+import org.mozilla.fenix.wallpapers.LegacyWallpaperMigration.Companion.TURNING_RED_WALLPAPER_TEXT_COLOR
+import java.io.File
+import java.util.Calendar
+import java.util.Date
+import kotlin.random.Random
+
+class WallpapersUseCasesTest {
+
+ // initialize this once, so it can be shared throughout tests
+ private val baseFakeDate = Date()
+ private val fakeCalendar = Calendar.getInstance()
+
+ private val appStore = AppStore()
+ private val mockSettings = mockk<Settings> {
+ every { currentWallpaperTextColor } returns 0L
+ every { currentWallpaperTextColor = any() } just Runs
+ every { currentWallpaperCardColorLight } returns 0L
+ every { currentWallpaperCardColorLight = any() } just Runs
+ every { currentWallpaperCardColorDark } returns 0L
+ every { currentWallpaperCardColorDark = any() } just Runs
+ every { shouldMigrateLegacyWallpaper } returns false
+ every { shouldMigrateLegacyWallpaper = any() } just Runs
+ every { shouldMigrateLegacyWallpaperCardColors } returns false
+ every { shouldMigrateLegacyWallpaperCardColors = any() } just Runs
+ }
+ private lateinit var mockMigrationHelper: LegacyWallpaperMigration
+
+ private val mockMetadataFetcher = mockk<WallpaperMetadataFetcher>()
+ private val mockDownloader = mockk<WallpaperDownloader> {
+ coEvery { downloadWallpaper(any()) } returns mockk()
+ }
+ private val mockFileManager = mockk<WallpaperFileManager> {
+ coEvery { clean(any(), any()) } returns mockk()
+ }
+
+ private val mockFolder: File = mockk()
+ private val downloadWallpaper: (Wallpaper) -> Wallpaper.ImageFileState = mockk(relaxed = true)
+
+ @Before
+ fun setup() {
+ mockMigrationHelper = spyk(
+ LegacyWallpaperMigration(
+ storageRootDirectory = mockFolder,
+ settings = mockSettings,
+ downloadWallpaper,
+ ),
+ )
+ }
+
+ @Test
+ fun `WHEN initializing THEN the default wallpaper is not downloaded`() = runTest {
+ val fakeRemoteWallpapers = listOf("first", "second", "third").map { name ->
+ makeFakeRemoteWallpaper(TimeRelation.LATER, name)
+ }
+ every { mockSettings.currentWallpaperName } returns ""
+ coEvery { mockFileManager.lookupExpiredWallpaper(any()) } returns null
+ coEvery { mockMetadataFetcher.downloadWallpaperList() } returns fakeRemoteWallpapers
+ coEvery { mockDownloader.downloadThumbnail(any()) } returns Wallpaper.ImageFileState.Downloaded
+
+ WallpapersUseCases.DefaultInitializeWallpaperUseCase(
+ appStore,
+ mockDownloader,
+ mockFileManager,
+ mockMetadataFetcher,
+ mockMigrationHelper,
+ mockSettings,
+ "en-US",
+ ).invoke()
+
+ appStore.waitUntilIdle()
+ coVerify(exactly = 0) { mockDownloader.downloadWallpaper(Wallpaper.Default) }
+ }
+
+ @Test
+ fun `WHEN initializing THEN default wallpaper is included in available wallpapers`() = runTest {
+ val fakeRemoteWallpapers = listOf("first", "second", "third").map { name ->
+ makeFakeRemoteWallpaper(TimeRelation.LATER, name)
+ }
+ every { mockSettings.currentWallpaperName } returns ""
+ coEvery { mockFileManager.lookupExpiredWallpaper(any()) } returns null
+ coEvery { mockMetadataFetcher.downloadWallpaperList() } returns fakeRemoteWallpapers
+ coEvery { mockDownloader.downloadThumbnail(any()) } returns Wallpaper.ImageFileState.Downloaded
+
+ WallpapersUseCases.DefaultInitializeWallpaperUseCase(
+ appStore,
+ mockDownloader,
+ mockFileManager,
+ mockMetadataFetcher,
+ mockMigrationHelper,
+ mockSettings,
+ "en-US",
+ ).invoke()
+
+ appStore.waitUntilIdle()
+ assertTrue(appStore.state.wallpaperState.availableWallpapers.contains(Wallpaper.Default))
+ }
+
+ @Test
+ fun `GIVEN wallpapers that expired WHEN invoking initialize use case THEN expired wallpapers are filtered out and cleaned up`() = runTest {
+ val fakeRemoteWallpapers = listOf("first", "second", "third").map { name ->
+ makeFakeRemoteWallpaper(TimeRelation.LATER, name)
+ }
+ val fakeExpiredRemoteWallpapers = listOf("expired").map { name ->
+ makeFakeRemoteWallpaper(TimeRelation.BEFORE, name)
+ }
+ val possibleWallpapers = fakeRemoteWallpapers + fakeExpiredRemoteWallpapers
+ every { mockSettings.currentWallpaperName } returns ""
+ coEvery { mockFileManager.lookupExpiredWallpaper(any()) } returns null
+ coEvery { mockMetadataFetcher.downloadWallpaperList() } returns possibleWallpapers
+ coEvery { mockDownloader.downloadThumbnail(any()) } returns Wallpaper.ImageFileState.Downloaded
+
+ WallpapersUseCases.DefaultInitializeWallpaperUseCase(
+ appStore,
+ mockDownloader,
+ mockFileManager,
+ mockMetadataFetcher,
+ mockMigrationHelper,
+ mockSettings,
+ "en-US",
+ ).invoke()
+
+ val expectedFilteredWallpaper = fakeExpiredRemoteWallpapers[0]
+ appStore.waitUntilIdle()
+ assertFalse(appStore.state.wallpaperState.availableWallpapers.contains(expectedFilteredWallpaper))
+ coVerify { mockFileManager.clean(Wallpaper.Default, fakeRemoteWallpapers) }
+ }
+
+ @Test
+ fun `GIVEN wallpapers that expired and an expired one is selected WHEN invoking initialize use case THEN selected wallpaper is not filtered out`() = runTest {
+ val fakeRemoteWallpapers = listOf("first", "second", "third").map { name ->
+ makeFakeRemoteWallpaper(TimeRelation.LATER, name)
+ }
+ val expiredWallpaper = makeFakeRemoteWallpaper(TimeRelation.BEFORE, "expired")
+ val allWallpapers = listOf(expiredWallpaper) + fakeRemoteWallpapers
+ every { mockSettings.currentWallpaperName } returns "expired"
+ coEvery { mockFileManager.lookupExpiredWallpaper(any()) } returns expiredWallpaper
+ coEvery { mockMetadataFetcher.downloadWallpaperList() } returns allWallpapers
+ coEvery { mockDownloader.downloadThumbnail(any()) } returns Wallpaper.ImageFileState.Downloaded
+
+ WallpapersUseCases.DefaultInitializeWallpaperUseCase(
+ appStore,
+ mockDownloader,
+ mockFileManager,
+ mockMetadataFetcher,
+ mockMigrationHelper,
+ mockSettings,
+ "en-US",
+ ).invoke()
+
+ val expectedWallpaper = expiredWallpaper.copy(
+ thumbnailFileState = Wallpaper.ImageFileState.Downloaded,
+ )
+ appStore.waitUntilIdle()
+ assertTrue(appStore.state.wallpaperState.availableWallpapers.contains(expectedWallpaper))
+ assertEquals(expiredWallpaper, appStore.state.wallpaperState.currentWallpaper)
+ }
+
+ @Test
+ fun `GIVEN wallpapers that expired and an expired one is selected and card colors have not been migrated WHEN invoking initialize use case THEN migrate card colors`() = runTest {
+ val fakeRemoteWallpapers = listOf("first", "second", "third").map { name ->
+ makeFakeRemoteWallpaper(TimeRelation.LATER, name)
+ }
+ val expiredWallpaper = makeFakeRemoteWallpaper(TimeRelation.BEFORE, TURNING_RED_PANDA_WALLPAPER_NAME)
+ val allWallpapers = listOf(expiredWallpaper) + fakeRemoteWallpapers
+ every { mockSettings.currentWallpaperName } returns TURNING_RED_PANDA_WALLPAPER_NAME
+ every { mockSettings.shouldMigrateLegacyWallpaperCardColors } returns true
+ every { mockSettings.currentWallpaperTextColor } returns TURNING_RED_WALLPAPER_TEXT_COLOR.toHexColor()
+ coEvery { mockFileManager.lookupExpiredWallpaper(any()) } returns expiredWallpaper
+ coEvery { mockMetadataFetcher.downloadWallpaperList() } returns allWallpapers
+ coEvery { mockDownloader.downloadThumbnail(any()) } returns Wallpaper.ImageFileState.Downloaded
+
+ WallpapersUseCases.DefaultInitializeWallpaperUseCase(
+ appStore,
+ mockDownloader,
+ mockFileManager,
+ mockMetadataFetcher,
+ mockMigrationHelper,
+ mockSettings,
+ "en-US",
+ ).invoke()
+
+ appStore.waitUntilIdle()
+
+ verify { mockMigrationHelper.migrateExpiredWallpaperCardColors() }
+ verify { mockSettings.currentWallpaperCardColorLight = TURNING_RED_PANDA_WALLPAPER_CARD_COLOR_LIGHT.toHexColor() }
+ verify { mockSettings.currentWallpaperCardColorDark = TURNING_RED_PANDA_WALLPAPER_CARD_COLOR_DARK.toHexColor() }
+ }
+
+ @Test
+ fun `GIVEN wallpapers that are in promotions outside of locale WHEN invoking initialize use case THEN promotional wallpapers are filtered out`() = runTest {
+ val fakeRemoteWallpapers = listOf("first", "second", "third").map { name ->
+ makeFakeRemoteWallpaper(TimeRelation.LATER, name)
+ }
+ val locale = "en-CA"
+ every { mockSettings.currentWallpaperName } returns ""
+ coEvery { mockFileManager.lookupExpiredWallpaper(any()) } returns null
+ coEvery { mockMetadataFetcher.downloadWallpaperList() } returns fakeRemoteWallpapers
+
+ WallpapersUseCases.DefaultInitializeWallpaperUseCase(
+ appStore,
+ mockDownloader,
+ mockFileManager,
+ mockMetadataFetcher,
+ mockMigrationHelper,
+ mockSettings,
+ locale,
+ ).invoke()
+
+ appStore.waitUntilIdle()
+ assertEquals(1, appStore.state.wallpaperState.availableWallpapers.size)
+ assertEquals(Wallpaper.Default, appStore.state.wallpaperState.availableWallpapers[0])
+ }
+
+ @Test
+ fun `GIVEN available wallpapers WHEN invoking initialize use case THEN available wallpaper thumbnails downloaded`() = runTest {
+ val fakeRemoteWallpapers = listOf("first", "second", "third").map { name ->
+ makeFakeRemoteWallpaper(TimeRelation.LATER, name)
+ }
+ every { mockSettings.currentWallpaperName } returns ""
+ coEvery { mockFileManager.lookupExpiredWallpaper(any()) } returns null
+ coEvery { mockMetadataFetcher.downloadWallpaperList() } returns fakeRemoteWallpapers
+ coEvery { mockDownloader.downloadThumbnail(any()) } returns Wallpaper.ImageFileState.Downloaded
+
+ WallpapersUseCases.DefaultInitializeWallpaperUseCase(
+ appStore,
+ mockDownloader,
+ mockFileManager,
+ mockMetadataFetcher,
+ mockMigrationHelper,
+ mockSettings,
+ "en-US",
+ ).invoke()
+
+ for (fakeRemoteWallpaper in fakeRemoteWallpapers) {
+ coVerify { mockDownloader.downloadThumbnail(fakeRemoteWallpaper) }
+ }
+ }
+
+ @Test
+ fun `GIVEN available wallpapers WHEN invoking initialize use case THEN thumbnails downloaded and the app store state is updated to reflect that`() = runTest {
+ val fakeRemoteWallpapers = listOf("first", "second", "third").map { name ->
+ makeFakeRemoteWallpaper(TimeRelation.LATER, name)
+ }
+ every { mockSettings.currentWallpaperName } returns ""
+ coEvery { mockFileManager.lookupExpiredWallpaper(any()) } returns null
+ coEvery { mockMetadataFetcher.downloadWallpaperList() } returns fakeRemoteWallpapers
+ coEvery { mockDownloader.downloadThumbnail(any()) } returns Wallpaper.ImageFileState.Downloaded
+
+ WallpapersUseCases.DefaultInitializeWallpaperUseCase(
+ appStore,
+ mockDownloader,
+ mockFileManager,
+ mockMetadataFetcher,
+ mockMigrationHelper,
+ mockSettings,
+ "en-US",
+ ).invoke()
+
+ for (fakeRemoteWallpaper in fakeRemoteWallpapers) {
+ coVerify { mockDownloader.downloadThumbnail(fakeRemoteWallpaper) }
+ }
+ appStore.waitUntilIdle()
+ assertTrue(
+ appStore.state.wallpaperState.availableWallpapers.all {
+ it.thumbnailFileState == Wallpaper.ImageFileState.Downloaded
+ },
+ )
+ }
+
+ @Test
+ fun `GIVEN thumbnail download fails WHEN invoking initialize use case THEN the app store state is updated to reflect that`() = runTest {
+ val fakeRemoteWallpapers = listOf("first", "second", "third").map { name ->
+ makeFakeRemoteWallpaper(TimeRelation.LATER, name)
+ }
+ val failedWallpaper = makeFakeRemoteWallpaper(TimeRelation.LATER, "failed")
+ every { mockSettings.currentWallpaperName } returns ""
+ coEvery { mockFileManager.lookupExpiredWallpaper(any()) } returns null
+ coEvery { mockMetadataFetcher.downloadWallpaperList() } returns listOf(failedWallpaper) + fakeRemoteWallpapers
+ coEvery { mockDownloader.downloadThumbnail(any()) } returns Wallpaper.ImageFileState.Downloaded
+ coEvery { mockDownloader.downloadThumbnail(failedWallpaper) } returns Wallpaper.ImageFileState.Error
+
+ WallpapersUseCases.DefaultInitializeWallpaperUseCase(
+ appStore,
+ mockDownloader,
+ mockFileManager,
+ mockMetadataFetcher,
+ mockMigrationHelper,
+ mockSettings,
+ "en-US",
+ ).invoke()
+
+ val expectedWallpaper = failedWallpaper.copy(thumbnailFileState = Wallpaper.ImageFileState.Error)
+ appStore.waitUntilIdle()
+ assertTrue(appStore.state.wallpaperState.availableWallpapers.contains(expectedWallpaper))
+ }
+
+ @Test
+ fun `GIVEN a wallpaper has not been selected WHEN invoking initialize use case THEN app store contains default`() = runTest {
+ val fakeRemoteWallpapers = listOf("first", "second", "third").map { name ->
+ makeFakeRemoteWallpaper(TimeRelation.LATER, name)
+ }
+ every { mockSettings.currentWallpaperName } returns ""
+ coEvery { mockFileManager.lookupExpiredWallpaper(any()) } returns null
+ coEvery { mockMetadataFetcher.downloadWallpaperList() } returns fakeRemoteWallpapers
+ coEvery { mockDownloader.downloadThumbnail(any()) } returns Wallpaper.ImageFileState.Downloaded
+
+ WallpapersUseCases.DefaultInitializeWallpaperUseCase(
+ appStore,
+ mockDownloader,
+ mockFileManager,
+ mockMetadataFetcher,
+ mockMigrationHelper,
+ mockSettings,
+ "en-US",
+ ).invoke()
+
+ appStore.waitUntilIdle()
+ assertTrue(appStore.state.wallpaperState.currentWallpaper == Wallpaper.Default)
+ }
+
+ @Test
+ fun `GIVEN a wallpaper is selected and there are available wallpapers WHEN invoking initialize use case THEN these are dispatched to the app store`() = runTest {
+ val selectedWallpaper = makeFakeRemoteWallpaper(TimeRelation.LATER, "selected")
+ val fakeRemoteWallpapers = listOf("first", "second", "third").map { name ->
+ makeFakeRemoteWallpaper(TimeRelation.LATER, name)
+ }
+ val possibleWallpapers = listOf(selectedWallpaper) + fakeRemoteWallpapers
+ every { mockSettings.currentWallpaperName } returns selectedWallpaper.name
+ coEvery { mockFileManager.lookupExpiredWallpaper(any()) } returns null
+ coEvery { mockMetadataFetcher.downloadWallpaperList() } returns possibleWallpapers
+ coEvery { mockDownloader.downloadThumbnail(any()) } returns Wallpaper.ImageFileState.Downloaded
+
+ WallpapersUseCases.DefaultInitializeWallpaperUseCase(
+ appStore,
+ mockDownloader,
+ mockFileManager,
+ mockMetadataFetcher,
+ mockMigrationHelper,
+ mockSettings,
+ "en-US",
+ ).invoke()
+
+ val expectedWallpapers = (listOf(Wallpaper.Default) + possibleWallpapers).map {
+ it.copy(thumbnailFileState = Wallpaper.ImageFileState.Downloaded)
+ }
+ appStore.waitUntilIdle()
+ assertEquals(selectedWallpaper, appStore.state.wallpaperState.currentWallpaper)
+ assertEquals(expectedWallpapers, appStore.state.wallpaperState.availableWallpapers)
+ }
+
+ @Test
+ fun `GIVEN wallpaper downloaded WHEN selecting a wallpaper THEN storage updated and app store receives dispatch`() = runTest {
+ val selectedWallpaper = makeFakeRemoteWallpaper(TimeRelation.LATER, "selected")
+ val slot = slot<String>()
+ coEvery { mockFileManager.lookupExpiredWallpaper(any()) } returns null
+ every { mockSettings.currentWallpaperName } returns ""
+ every { mockSettings.currentWallpaperName = capture(slot) } just runs
+ coEvery { mockFileManager.wallpaperImagesExist(selectedWallpaper) } returns true
+
+ val wallpaperFileState = WallpapersUseCases.DefaultSelectWallpaperUseCase(
+ mockSettings,
+ appStore,
+ mockFileManager,
+ mockDownloader,
+ ).invoke(selectedWallpaper)
+
+ appStore.waitUntilIdle()
+ assertEquals(selectedWallpaper.name, slot.captured)
+ assertEquals(selectedWallpaper, appStore.state.wallpaperState.currentWallpaper)
+ assertEquals(wallpaperFileState, Wallpaper.ImageFileState.Downloaded)
+ }
+
+ @Test
+ fun `GIVEN wallpaper is not downloaded WHEN selecting a wallpaper and download succeeds THEN storage updated and app store receives dispatch`() = runTest {
+ val selectedWallpaper = makeFakeRemoteWallpaper(TimeRelation.LATER, "selected")
+ val slot = slot<String>()
+ val mockAppStore = mockk<AppStore>(relaxed = true)
+ coEvery { mockFileManager.lookupExpiredWallpaper(any()) } returns null
+ every { mockSettings.currentWallpaperName } returns ""
+ every { mockSettings.currentWallpaperName = capture(slot) } just runs
+ coEvery { mockFileManager.wallpaperImagesExist(selectedWallpaper) } returns false
+ coEvery { mockDownloader.downloadWallpaper(selectedWallpaper) } returns Wallpaper.ImageFileState.Downloaded
+
+ val wallpaperFileState = WallpapersUseCases.DefaultSelectWallpaperUseCase(
+ mockSettings,
+ mockAppStore,
+ mockFileManager,
+ mockDownloader,
+ ).invoke(selectedWallpaper)
+
+ verify { mockAppStore.dispatch(AppAction.WallpaperAction.UpdateWallpaperDownloadState(selectedWallpaper, Wallpaper.ImageFileState.Downloading)) }
+ verify { mockAppStore.dispatch(AppAction.WallpaperAction.UpdateWallpaperDownloadState(selectedWallpaper, Wallpaper.ImageFileState.Downloaded)) }
+ verify { mockAppStore.dispatch(AppAction.WallpaperAction.UpdateCurrentWallpaper(selectedWallpaper)) }
+ assertEquals(wallpaperFileState, Wallpaper.ImageFileState.Downloaded)
+ }
+
+ @Test
+ fun `GIVEN wallpaper is not downloaded WHEN selecting a wallpaper and any download fails THEN wallpaper not set and app store receives dispatch`() = runTest {
+ val selectedWallpaper = makeFakeRemoteWallpaper(TimeRelation.LATER, "selected")
+ val slot = slot<String>()
+ val mockAppStore = mockk<AppStore>(relaxed = true)
+ coEvery { mockFileManager.lookupExpiredWallpaper(any()) } returns null
+ every { mockSettings.currentWallpaperName } returns ""
+ every { mockSettings.currentWallpaperName = capture(slot) } just runs
+ coEvery { mockFileManager.wallpaperImagesExist(selectedWallpaper) } returns false
+ coEvery { mockDownloader.downloadWallpaper(selectedWallpaper) } returns Wallpaper.ImageFileState.Error
+
+ val wallpaperFileState = WallpapersUseCases.DefaultSelectWallpaperUseCase(
+ mockSettings,
+ mockAppStore,
+ mockFileManager,
+ mockDownloader,
+ ).invoke(selectedWallpaper)
+
+ verify { mockAppStore.dispatch(AppAction.WallpaperAction.UpdateWallpaperDownloadState(selectedWallpaper, Wallpaper.ImageFileState.Downloading)) }
+ verify { mockAppStore.dispatch(AppAction.WallpaperAction.UpdateWallpaperDownloadState(selectedWallpaper, Wallpaper.ImageFileState.Error)) }
+ assertEquals(wallpaperFileState, Wallpaper.ImageFileState.Error)
+ }
+
+ @Test
+ fun `GIVEN a wallpaper with no text color WHEN it is is selected THEN persist the wallpaper name and missing text color and dispatch the update`() {
+ every { mockSettings.currentWallpaperName = any() } just Runs
+ val appStore = mockk<AppStore>(relaxed = true)
+ val wallpaperFileState = WallpapersUseCases.DefaultSelectWallpaperUseCase(
+ settings = mockSettings,
+ appStore = appStore,
+ fileManager = mockk(),
+ downloader = mockk(),
+ )
+ val wallpaper: Wallpaper = mockk {
+ every { name } returns "Test"
+ every { textColor } returns null
+ every { cardColorLight } returns null
+ every { cardColorDark } returns null
+ }
+
+ wallpaperFileState.selectWallpaper(wallpaper)
+
+ verify { mockSettings.currentWallpaperName = "Test" }
+ verify { mockSettings.currentWallpaperTextColor = 0L }
+ verify { appStore.dispatch(AppAction.WallpaperAction.UpdateCurrentWallpaper(wallpaper)) }
+ }
+
+ @Test
+ fun `GIVEN a wallpaper with available text color WHEN it is is selected THEN persist the wallpaper name and text color and dispatch the update`() {
+ every { mockSettings.currentWallpaperName = any() } just Runs
+ val appStore = mockk<AppStore>(relaxed = true)
+ val wallpaperFileState = WallpapersUseCases.DefaultSelectWallpaperUseCase(
+ settings = mockSettings,
+ appStore = appStore,
+ fileManager = mockk(),
+ downloader = mockk(),
+ )
+ val wallpaper: Wallpaper = mockk {
+ every { name } returns "Test"
+ every { textColor } returns 321L
+ every { cardColorLight } returns 321L
+ every { cardColorDark } returns 321L
+ }
+
+ wallpaperFileState.selectWallpaper(wallpaper)
+
+ verify { mockSettings.currentWallpaperName = "Test" }
+ verify { mockSettings.currentWallpaperTextColor = 321L }
+ verify { appStore.dispatch(AppAction.WallpaperAction.UpdateCurrentWallpaper(wallpaper)) }
+ }
+
+ @Test
+ fun `GIVEN the portrait orientation WHEN bitmap is loaded THEN loadWallpaperFromDisk method is called with the correct wallpaper and orientation`() =
+ runTest {
+ val wallpaper: Wallpaper = mockk {
+ every { name } returns "test"
+ }
+ val orientation = Configuration.ORIENTATION_PORTRAIT
+ val defaultLoadBitmapUseCase = spyk(WallpapersUseCases.DefaultLoadBitmapUseCase { mockFolder })
+ coEvery { defaultLoadBitmapUseCase.loadWallpaperFromDisk(wallpaper, orientation) } returns mockk()
+
+ defaultLoadBitmapUseCase.invoke(wallpaper, orientation)
+
+ coVerify { defaultLoadBitmapUseCase.loadWallpaperFromDisk(wallpaper, orientation) }
+ }
+
+ @Test
+ fun `GIVEN the landscape orientation WHEN bitmap is loaded THEN loadWallpaperFromDisk method is called with the correct wallpaper and orientation`() =
+ runTest {
+ val wallpaper: Wallpaper = mockk {
+ every { name } returns "test"
+ }
+ val orientation = Configuration.ORIENTATION_LANDSCAPE
+ val defaultLoadBitmapUseCase = spyk(WallpapersUseCases.DefaultLoadBitmapUseCase { mockFolder })
+ coEvery { defaultLoadBitmapUseCase.loadWallpaperFromDisk(wallpaper, orientation) } returns mockk()
+
+ defaultLoadBitmapUseCase.invoke(wallpaper, orientation)
+
+ coVerify { defaultLoadBitmapUseCase.loadWallpaperFromDisk(wallpaper, orientation) }
+ }
+
+ private enum class TimeRelation {
+ BEFORE,
+ NOW,
+ LATER,
+ }
+
+ /**
+ * [timeRelation] should specify a time relative to the time the tests are run
+ */
+ private fun makeFakeRemoteWallpaper(
+ timeRelation: TimeRelation,
+ name: String = "name",
+ isInPromo: Boolean = true,
+ ): Wallpaper {
+ fakeCalendar.time = baseFakeDate
+ when (timeRelation) {
+ TimeRelation.BEFORE -> fakeCalendar.add(Calendar.DATE, -5)
+ TimeRelation.NOW -> Unit
+ TimeRelation.LATER -> fakeCalendar.add(Calendar.DATE, 5)
+ }
+ val relativeTime = fakeCalendar.time
+ return if (isInPromo) {
+ Wallpaper(
+ name = name,
+ collection = Wallpaper.Collection(
+ name = Wallpaper.firefoxCollectionName,
+ heading = null,
+ description = null,
+ availableLocales = listOf("en-US"),
+ startDate = null,
+ endDate = relativeTime,
+ learnMoreUrl = null,
+ ),
+ textColor = Random.nextLong(),
+ cardColorLight = Random.nextLong(),
+ cardColorDark = Random.nextLong(),
+ thumbnailFileState = Wallpaper.ImageFileState.Unavailable,
+ assetsFileState = Wallpaper.ImageFileState.Unavailable,
+ )
+ } else {
+ Wallpaper(
+ name = name,
+ collection = Wallpaper.Collection(
+ name = Wallpaper.firefoxCollectionName,
+ heading = null,
+ description = null,
+ availableLocales = null,
+ startDate = null,
+ endDate = relativeTime,
+ learnMoreUrl = null,
+ ),
+ textColor = Random.nextLong(),
+ cardColorLight = Random.nextLong(),
+ cardColorDark = Random.nextLong(),
+ thumbnailFileState = Wallpaper.ImageFileState.Unavailable,
+ assetsFileState = Wallpaper.ImageFileState.Unavailable,
+ )
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/whatsnew/WhatsNewStorageTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/whatsnew/WhatsNewStorageTest.kt
new file mode 100644
index 0000000000..59c2794af7
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/whatsnew/WhatsNewStorageTest.kt
@@ -0,0 +1,58 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.whatsnew
+
+import androidx.preference.PreferenceManager
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.ext.clearAndCommit
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+
+@RunWith(FenixRobolectricTestRunner::class)
+class WhatsNewStorageTest {
+
+ private lateinit var storage: SharedPreferenceWhatsNewStorage
+
+ @Before
+ fun setUp() {
+ storage = SharedPreferenceWhatsNewStorage(testContext)
+ PreferenceManager.getDefaultSharedPreferences(testContext).clearAndCommit()
+ }
+
+ @Test
+ fun testGettingAndSettingAVersion() {
+ val version = WhatsNewVersion("3.0")
+ storage.setVersion(version)
+
+ val storedVersion = storage.getVersion()
+ Assert.assertEquals(version, storedVersion)
+ }
+
+ @Test
+ fun testGettingAndSettingTheDateOfUpdate() {
+ val currentTime = System.currentTimeMillis()
+ val twoDaysAgo = (currentTime - DAY_IN_MILLIS * 2)
+ storage.setDateOfUpdate(twoDaysAgo)
+
+ val storedDate = storage.getDaysSinceUpdate()
+ Assert.assertEquals(2, storedDate)
+ }
+
+ @Test
+ fun testGettingAndSettingHasBeenCleared() {
+ val hasBeenCleared = true
+ storage.setWhatsNewHasBeenCleared(hasBeenCleared)
+
+ val storedHasBeenCleared = storage.getWhatsNewHasBeenCleared()
+ Assert.assertEquals(hasBeenCleared, storedHasBeenCleared)
+ }
+
+ companion object {
+ const val DAY_IN_MILLIS = 3600 * 1000 * 24
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/whatsnew/WhatsNewTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/whatsnew/WhatsNewTest.kt
new file mode 100644
index 0000000000..c7226d262e
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/whatsnew/WhatsNewTest.kt
@@ -0,0 +1,94 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.whatsnew
+
+import androidx.preference.PreferenceManager
+import io.mockk.every
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.ext.clearAndCommit
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.helpers.perf.TestStrictModeManager
+
+@RunWith(FenixRobolectricTestRunner::class)
+class WhatsNewTest {
+
+ private lateinit var storage: SharedPreferenceWhatsNewStorage
+
+ @Before
+ fun setup() {
+ storage = SharedPreferenceWhatsNewStorage(testContext)
+ PreferenceManager.getDefaultSharedPreferences(testContext).clearAndCommit()
+ WhatsNew.wasUpdatedRecently = null
+ }
+
+ @Test
+ fun `should highlight after fresh install`() {
+ every { testContext.components.strictMode } returns TestStrictModeManager()
+ assertEquals(true, WhatsNew.shouldHighlightWhatsNew(testContext))
+ }
+
+ @Test
+ fun `should highlight if new version has been installed less than 3 days`() {
+ storage.setVersion(WhatsNewVersion("1.0"))
+ storage.setDateOfUpdate(System.currentTimeMillis() - WhatsNewStorageTest.DAY_IN_MILLIS)
+
+ assertEquals(true, WhatsNew.shouldHighlightWhatsNew(WhatsNewVersion("2.0"), storage))
+ }
+
+ @Test
+ fun `should not highlight if new version has been installed more than 3 days`() {
+ storage.setVersion(WhatsNewVersion("1.0"))
+ assertEquals(true, WhatsNew.shouldHighlightWhatsNew(WhatsNewVersion("2.0"), storage))
+
+ // Simulate an app "restart" by resetting `wasUpdatedRecently`
+ WhatsNew.wasUpdatedRecently = null
+ // Simulate time passing
+ storage.setDateOfUpdate(System.currentTimeMillis() - WhatsNewStorageTest.DAY_IN_MILLIS * 3)
+
+ assertEquals(false, WhatsNew.shouldHighlightWhatsNew(WhatsNewVersion("2.0"), storage))
+ }
+
+ @Test
+ fun `should not highlight if new version is only a minor update`() {
+ storage.setVersion(WhatsNewVersion("1.0"))
+ assertEquals(false, WhatsNew.shouldHighlightWhatsNew(WhatsNewVersion("1.0.1"), storage))
+
+ WhatsNew.wasUpdatedRecently = null
+
+ storage.setVersion(WhatsNewVersion("1.0"))
+ assertEquals(false, WhatsNew.shouldHighlightWhatsNew(WhatsNewVersion("1.1"), storage))
+ }
+
+ @Test
+ fun `should highlight if new version is a major update`() {
+ storage.setVersion(WhatsNewVersion("1.0"))
+ assertEquals(true, WhatsNew.shouldHighlightWhatsNew(WhatsNewVersion("2.0"), storage))
+
+ WhatsNew.wasUpdatedRecently = null
+
+ storage.setVersion(WhatsNewVersion("1.2"))
+ assertEquals(true, WhatsNew.shouldHighlightWhatsNew(WhatsNewVersion("2.0"), storage))
+
+ WhatsNew.wasUpdatedRecently = null
+
+ storage.setVersion(WhatsNewVersion("3.2"))
+ assertEquals(true, WhatsNew.shouldHighlightWhatsNew(WhatsNewVersion("4.2"), storage))
+ }
+
+ @Test
+ fun `should not highlight after user viewed what's new`() {
+ every { testContext.components.strictMode } returns TestStrictModeManager()
+ assertEquals(true, WhatsNew.shouldHighlightWhatsNew(testContext))
+
+ WhatsNew.userViewedWhatsNew(testContext)
+
+ assertEquals(false, WhatsNew.shouldHighlightWhatsNew(testContext))
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/whatsnew/WhatsNewVersionTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/whatsnew/WhatsNewVersionTest.kt
new file mode 100644
index 0000000000..51a83f0887
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/whatsnew/WhatsNewVersionTest.kt
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.whatsnew
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class WhatsNewVersionTest {
+ @Test
+ fun testMajorVersionNumber() {
+ val versionOne = WhatsNewVersion("1.2.0")
+ assertEquals(1, versionOne.majorVersionNumber)
+
+ val versionTwo = WhatsNewVersion("2.4.1")
+ assertEquals(2, versionTwo.majorVersionNumber)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/widget/SearchWidgetProviderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/widget/SearchWidgetProviderTest.kt
new file mode 100644
index 0000000000..c5bcacf601
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/widget/SearchWidgetProviderTest.kt
@@ -0,0 +1,190 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.widget
+
+import android.appwidget.AppWidgetManager
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.slot
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.R
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.gecko.search.SearchWidgetProvider
+import org.mozilla.gecko.search.SearchWidgetProviderSize
+
+@RunWith(FenixRobolectricTestRunner::class)
+class SearchWidgetProviderTest {
+
+ @Test
+ fun testGetLayoutSize() {
+ val sizes = mapOf(
+ 0 to SearchWidgetProviderSize.EXTRA_SMALL_V1,
+ 10 to SearchWidgetProviderSize.EXTRA_SMALL_V1,
+ 63 to SearchWidgetProviderSize.EXTRA_SMALL_V1,
+ 64 to SearchWidgetProviderSize.EXTRA_SMALL_V2,
+ 99 to SearchWidgetProviderSize.EXTRA_SMALL_V2,
+ 100 to SearchWidgetProviderSize.SMALL,
+ 191 to SearchWidgetProviderSize.SMALL,
+ 192 to SearchWidgetProviderSize.MEDIUM,
+ 255 to SearchWidgetProviderSize.MEDIUM,
+ 256 to SearchWidgetProviderSize.LARGE,
+ 1000 to SearchWidgetProviderSize.LARGE,
+ )
+
+ for ((dp, layoutSize) in sizes) {
+ assertEquals(layoutSize, SearchWidgetProvider.getLayoutSize(dp))
+ }
+ }
+
+ @Test
+ fun testGetLargeLayout() {
+ assertEquals(
+ R.layout.search_widget_large,
+ SearchWidgetProvider.getLayout(SearchWidgetProviderSize.LARGE, showMic = false),
+ )
+ assertEquals(
+ R.layout.search_widget_large,
+ SearchWidgetProvider.getLayout(SearchWidgetProviderSize.LARGE, showMic = true),
+ )
+ }
+
+ @Test
+ fun testGetMediumLayout() {
+ assertEquals(
+ R.layout.search_widget_medium,
+ SearchWidgetProvider.getLayout(SearchWidgetProviderSize.MEDIUM, showMic = false),
+ )
+ assertEquals(
+ R.layout.search_widget_medium,
+ SearchWidgetProvider.getLayout(SearchWidgetProviderSize.MEDIUM, showMic = true),
+ )
+ }
+
+ @Test
+ fun testGetSmallLayout() {
+ assertEquals(
+ R.layout.search_widget_small_no_mic,
+ SearchWidgetProvider.getLayout(SearchWidgetProviderSize.SMALL, showMic = false),
+ )
+ assertEquals(
+ R.layout.search_widget_small,
+ SearchWidgetProvider.getLayout(SearchWidgetProviderSize.SMALL, showMic = true),
+ )
+ }
+
+ @Test
+ fun testGetExtraSmall2Layout() {
+ assertEquals(
+ R.layout.search_widget_extra_small_v2,
+ SearchWidgetProvider.getLayout(SearchWidgetProviderSize.EXTRA_SMALL_V2, showMic = false),
+ )
+ assertEquals(
+ R.layout.search_widget_extra_small_v2,
+ SearchWidgetProvider.getLayout(SearchWidgetProviderSize.EXTRA_SMALL_V2, showMic = true),
+ )
+ }
+
+ @Test
+ fun testGetExtraSmall1Layout() {
+ assertEquals(
+ R.layout.search_widget_extra_small_v1,
+ SearchWidgetProvider.getLayout(SearchWidgetProviderSize.EXTRA_SMALL_V1, showMic = false),
+ )
+ assertEquals(
+ R.layout.search_widget_extra_small_v1,
+ SearchWidgetProvider.getLayout(SearchWidgetProviderSize.EXTRA_SMALL_V1, showMic = true),
+ )
+ }
+
+ @Test
+ fun testGetText() {
+ val context = mockk<Context>()
+ every { context.getString(R.string.search_widget_text_short) } returns "Search"
+ every { context.getString(R.string.search_widget_text_long) } returns "Search the web"
+
+ assertEquals(
+ "Search the web",
+ SearchWidgetProvider.getText(SearchWidgetProviderSize.LARGE, context),
+ )
+ assertEquals(
+ "Search",
+ SearchWidgetProvider.getText(SearchWidgetProviderSize.MEDIUM, context),
+ )
+ assertNull(SearchWidgetProvider.getText(SearchWidgetProviderSize.SMALL, context))
+ assertNull(SearchWidgetProvider.getText(SearchWidgetProviderSize.EXTRA_SMALL_V1, context))
+ assertNull(SearchWidgetProvider.getText(SearchWidgetProviderSize.EXTRA_SMALL_V2, context))
+ }
+
+ @Test
+ fun `GIVEN voice search is disabled WHEN createVoiceSearchIntent is called THEN it returns null`() {
+ val widgetProvider = SearchWidgetProvider()
+ val context: Context = mockk {
+ every { settings().shouldShowVoiceSearch } returns false
+ }
+
+ val result = widgetProvider.createVoiceSearchIntent(context)
+
+ assertNull(result)
+ }
+
+ @Test
+ fun `GIVEN widgets set on screen shown WHEN updateAllWidgets is called THEN it sends a broadcast to update all widgets`() {
+ try {
+ mockkStatic(AppWidgetManager::class)
+ val widgetManager: AppWidgetManager = mockk()
+ every { AppWidgetManager.getInstance(any()) } returns widgetManager
+ val componentNameCaptor = slot<ComponentName>()
+ val widgetsToUpdate = intArrayOf(1, 2)
+ every { widgetManager.getAppWidgetIds(capture(componentNameCaptor)) } returns widgetsToUpdate
+ val context: Context = mockk(relaxed = true)
+ val intentCaptor = slot<Intent>()
+ every { context.sendBroadcast(capture(intentCaptor)) } just Runs
+
+ SearchWidgetProvider.updateAllWidgets(context)
+
+ verify { context.sendBroadcast(any()) }
+ assertEquals(SearchWidgetProvider::class.java.name, componentNameCaptor.captured.className)
+ assertEquals(SearchWidgetProvider::class.java.name, intentCaptor.captured.component!!.className)
+ assertEquals(AppWidgetManager.ACTION_APPWIDGET_UPDATE, intentCaptor.captured.action)
+ @Suppress("DEPRECATION")
+ assertEquals(widgetsToUpdate, intentCaptor.captured.extras!!.get(AppWidgetManager.EXTRA_APPWIDGET_IDS))
+ } finally {
+ unmockkStatic(AppWidgetManager::class)
+ }
+ }
+
+ @Test
+ fun `GIVEN no widgets set shown WHEN updateAllWidgets is called THEN it does not try to update widgets`() {
+ try {
+ mockkStatic(AppWidgetManager::class)
+ val widgetManager: AppWidgetManager = mockk()
+ every { AppWidgetManager.getInstance(any()) } returns widgetManager
+ val componentNameCaptor = slot<ComponentName>()
+ val widgetsToUpdate = intArrayOf()
+ every { widgetManager.getAppWidgetIds(capture(componentNameCaptor)) } returns widgetsToUpdate
+ val context: Context = mockk(relaxed = true)
+ val intentCaptor = slot<Intent>()
+ every { context.sendBroadcast(capture(intentCaptor)) } just Runs
+
+ SearchWidgetProvider.updateAllWidgets(context)
+
+ verify(exactly = 0) { context.sendBroadcast(any()) }
+ } finally {
+ unmockkStatic(AppWidgetManager::class)
+ }
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/widget/VoiceSearchActivityTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/widget/VoiceSearchActivityTest.kt
new file mode 100644
index 0000000000..9f427b5b2a
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/widget/VoiceSearchActivityTest.kt
@@ -0,0 +1,186 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.widget
+
+import android.app.Activity.RESULT_CANCELED
+import android.app.Activity.RESULT_OK
+import android.content.ComponentName
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Bundle
+import android.speech.RecognizerIntent.ACTION_RECOGNIZE_SPEECH
+import android.speech.RecognizerIntent.EXTRA_LANGUAGE_MODEL
+import android.speech.RecognizerIntent.EXTRA_RESULTS
+import android.speech.RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
+import androidx.activity.result.ActivityResult
+import androidx.test.core.app.ApplicationProvider
+import io.mockk.every
+import io.mockk.mockk
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.FenixApplication
+import org.mozilla.fenix.HomeActivity.Companion.OPEN_TO_BROWSER_AND_LOAD
+import org.mozilla.fenix.IntentReceiverActivity
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.helpers.perf.TestStrictModeManager
+import org.mozilla.fenix.widget.VoiceSearchActivity.Companion.PREVIOUS_INTENT
+import org.mozilla.fenix.widget.VoiceSearchActivity.Companion.SPEECH_PROCESSING
+import org.robolectric.Robolectric
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.android.controller.ActivityController
+import org.robolectric.shadows.ShadowActivity
+
+@RunWith(FenixRobolectricTestRunner::class)
+class VoiceSearchActivityTest {
+
+ private lateinit var controller: ActivityController<VoiceSearchActivity>
+ private lateinit var activity: VoiceSearchActivity
+ private lateinit var shadow: ShadowActivity
+
+ @Before
+ fun setup() {
+ val intent = Intent()
+ intent.putExtra(SPEECH_PROCESSING, true)
+
+ controller = Robolectric.buildActivity(VoiceSearchActivity::class.java, intent)
+ activity = controller.get()
+ shadow = shadowOf(activity)
+ }
+
+ private fun allowVoiceIntentToResolveActivity() {
+ val context = ApplicationProvider.getApplicationContext<FenixApplication>()
+ val shadowPackageManager = shadowOf(context.packageManager)
+ val component = ComponentName("com.test", "Test")
+ shadowPackageManager.addActivityIfNotPresent(component)
+ shadowPackageManager.addIntentFilterForActivity(
+ component,
+ IntentFilter(ACTION_RECOGNIZE_SPEECH).apply { addCategory(Intent.CATEGORY_DEFAULT) },
+ )
+ }
+
+ @Test
+ fun `process intent with speech processing set to true`() {
+ every { testContext.components.analytics } returns mockk(relaxed = true)
+ every { testContext.components.strictMode } returns TestStrictModeManager()
+ allowVoiceIntentToResolveActivity()
+ controller.create()
+
+ val intentForResult = shadow.peekNextStartedActivityForResult()
+ assertEquals(ACTION_RECOGNIZE_SPEECH, intentForResult.intent.action)
+ assertEquals(
+ LANGUAGE_MODEL_FREE_FORM,
+ intentForResult.intent.getStringExtra(EXTRA_LANGUAGE_MODEL),
+ )
+ }
+
+ @Test
+ fun `process intent with speech processing set to false`() {
+ allowVoiceIntentToResolveActivity()
+ val intent = Intent()
+ intent.putExtra(SPEECH_PROCESSING, false)
+
+ val controller = Robolectric.buildActivity(VoiceSearchActivity::class.java, intent)
+ val activity = controller.get()
+
+ controller.create()
+
+ assertTrue(activity.isFinishing)
+ }
+
+ @Test
+ fun `process null intent`() {
+ allowVoiceIntentToResolveActivity()
+ val controller = Robolectric.buildActivity(VoiceSearchActivity::class.java, null)
+ val activity = controller.get()
+
+ controller.create()
+
+ assertTrue(activity.isFinishing)
+ }
+
+ @Test
+ fun `save previous intent to instance state`() {
+ allowVoiceIntentToResolveActivity()
+ val previousIntent = Intent().apply {
+ putExtra(SPEECH_PROCESSING, true)
+ }
+ val savedInstanceState = Bundle().apply {
+ putParcelable(PREVIOUS_INTENT, previousIntent)
+ }
+ val outState = Bundle()
+
+ controller.create(savedInstanceState)
+ controller.saveInstanceState(outState)
+
+ @Suppress("DEPRECATION")
+ assertEquals(previousIntent, outState.getParcelable<Intent>(PREVIOUS_INTENT))
+ }
+
+ @Test
+ fun `process intent with speech processing in previous intent set to true`() {
+ allowVoiceIntentToResolveActivity()
+ val savedInstanceState = Bundle()
+ val previousIntent = Intent().apply {
+ putExtra(SPEECH_PROCESSING, true)
+ }
+ savedInstanceState.putParcelable(PREVIOUS_INTENT, previousIntent)
+
+ controller.create(savedInstanceState)
+
+ assertFalse(activity.isFinishing)
+ assertNull(shadow.peekNextStartedActivityForResult())
+ }
+
+ @Test
+ fun `handle no activity able to resolve voice intent`() {
+ controller.create()
+ assertTrue(activity.isFinishing)
+ }
+
+ @Test
+ fun `handle speech result`() {
+ every { testContext.components.analytics } returns mockk(relaxed = true)
+ every { testContext.components.strictMode } returns TestStrictModeManager()
+ allowVoiceIntentToResolveActivity()
+ controller.create()
+
+ val resultIntent = Intent().apply {
+ putStringArrayListExtra(EXTRA_RESULTS, arrayListOf("hello world"))
+ }
+ val result = ActivityResult(RESULT_OK, resultIntent)
+ activity.handleActivityResult(result)
+
+ val browserIntent = shadow.peekNextStartedActivity()
+
+ assertTrue(activity.isFinishing)
+ assertEquals(
+ ComponentName(activity, IntentReceiverActivity::class.java),
+ browserIntent.component,
+ )
+ assertEquals("hello world", browserIntent.getStringExtra(SPEECH_PROCESSING))
+ assertTrue(browserIntent.getBooleanExtra(OPEN_TO_BROWSER_AND_LOAD, false))
+ }
+
+ @Test
+ fun `handle invalid result code`() {
+ every { testContext.components.analytics } returns mockk(relaxed = true)
+ every { testContext.components.strictMode } returns TestStrictModeManager()
+ allowVoiceIntentToResolveActivity()
+ controller.create()
+
+ val resultIntent = Intent()
+ val result = ActivityResult(RESULT_CANCELED, resultIntent)
+ activity.handleActivityResult(result)
+
+ assertTrue(activity.isFinishing)
+ }
+}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wifi/WifiConnectionMonitorTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wifi/WifiConnectionMonitorTest.kt
new file mode 100644
index 0000000000..7ac567d4bd
--- /dev/null
+++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/wifi/WifiConnectionMonitorTest.kt
@@ -0,0 +1,190 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.wifi
+
+import android.app.Application
+import android.net.ConnectivityManager
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
+import org.mozilla.fenix.utils.Settings
+
+@RunWith(FenixRobolectricTestRunner::class)
+class WifiConnectionMonitorTest {
+ lateinit var settings: Settings
+ lateinit var connectivityManager: ConnectivityManager
+ lateinit var wifiConnectionMonitor: WifiConnectionMonitor
+
+ @Before
+ fun setUp() {
+ settings = mockk(relaxed = true)
+ wifiConnectionMonitor = WifiConnectionMonitor(testContext as Application)
+ connectivityManager = spyk(wifiConnectionMonitor.connectivityManager)
+ wifiConnectionMonitor.connectivityManager = connectivityManager
+ }
+
+ @Test
+ fun `WHEN the feature starts THEN all the network callbacks must be registered`() {
+ val spyWifiConnectionMonitor = spyk(wifiConnectionMonitor)
+
+ spyWifiConnectionMonitor.connectivityManager = connectivityManager
+
+ spyWifiConnectionMonitor.start()
+
+ verify(exactly = 1) {
+ connectivityManager.registerNetworkCallback(
+ any(),
+ wifiConnectionMonitor.frameworkListener,
+ )
+ }
+
+ verify(exactly = 1) {
+ spyWifiConnectionMonitor.notifyListeners(false)
+ }
+
+ assertTrue(spyWifiConnectionMonitor.isRegistered)
+ assertFalse(spyWifiConnectionMonitor.lastKnownStateWasAvailable!!)
+ }
+
+ @Test
+ fun `WHEN the feature starts multiple times THEN the network callbacks must be registered once`() {
+ wifiConnectionMonitor.isRegistered = true
+
+ wifiConnectionMonitor.start()
+ wifiConnectionMonitor.start()
+
+ verify(exactly = 0) {
+ connectivityManager.registerNetworkCallback(
+ any(),
+ wifiConnectionMonitor.frameworkListener,
+ )
+ }
+ }
+
+ @Test
+ fun `WHEN the feature stops THEN the network callbacks must be unregistered`() {
+ wifiConnectionMonitor.start()
+ wifiConnectionMonitor.stop()
+
+ verify {
+ wifiConnectionMonitor.connectivityManager.unregisterNetworkCallback(
+ wifiConnectionMonitor.frameworkListener,
+ )
+ }
+
+ assertFalse(wifiConnectionMonitor.isRegistered)
+ assertTrue(wifiConnectionMonitor.callbacks.isEmpty())
+ assertNull(wifiConnectionMonitor.lastKnownStateWasAvailable)
+ }
+
+ @Test
+ fun `WHEN the feature gets stopped multiple time THEN the network callbacks must be unregistered once`() {
+ wifiConnectionMonitor.isRegistered = false
+
+ wifiConnectionMonitor.stop()
+
+ verify(exactly = 0) {
+ connectivityManager.unregisterNetworkCallback(wifiConnectionMonitor.frameworkListener)
+ }
+ }
+
+ @Test
+ fun `WHEN adding a listener THEN should be added to the callback queue`() {
+ wifiConnectionMonitor.addOnWifiConnectedChangedListener({})
+
+ assertFalse(wifiConnectionMonitor.callbacks.isEmpty())
+ }
+
+ @Test
+ fun `GIVEN a network status is known WHEN adding a new listener THEN the listener will be notified`() {
+ var wasNotified: Boolean? = null
+ wifiConnectionMonitor.lastKnownStateWasAvailable = false
+
+ wifiConnectionMonitor.addOnWifiConnectedChangedListener {
+ wasNotified = it
+ }
+
+ assertFalse(wasNotified!!)
+ assertFalse(wifiConnectionMonitor.callbacks.isEmpty())
+ }
+
+ @Test
+ fun `WHEN removing a listener THEN it will be removed from the listeners queue`() {
+ assertTrue(wifiConnectionMonitor.callbacks.isEmpty())
+
+ val callback: (Boolean) -> Unit = {}
+ wifiConnectionMonitor.addOnWifiConnectedChangedListener(callback)
+
+ assertFalse(wifiConnectionMonitor.callbacks.isEmpty())
+
+ wifiConnectionMonitor.removeOnWifiConnectedChangedListener(callback)
+
+ assertTrue(wifiConnectionMonitor.callbacks.isEmpty())
+ }
+
+ @Test
+ fun `WHEN the connection is lost THEN listeners will be notified`() {
+ var wasNotified: Boolean? = null
+
+ val callback: (Boolean) -> Unit = { wasNotified = it }
+
+ wifiConnectionMonitor.addOnWifiConnectedChangedListener(callback)
+ wifiConnectionMonitor.frameworkListener.onLost(mockk())
+
+ assertFalse(wasNotified!!)
+ }
+
+ @Test
+ fun `WHEN the connection is available THEN listeners will be notified`() {
+ var wasNotified: Boolean? = null
+
+ val callback: (Boolean) -> Unit = { wasNotified = it }
+
+ wifiConnectionMonitor.addOnWifiConnectedChangedListener(callback)
+ wifiConnectionMonitor.frameworkListener.onAvailable(mockk())
+
+ assertTrue(wasNotified!!)
+ }
+
+ @Test
+ fun `GIVEN multiple listeners were added WHEN there is an update THEN all listeners must be notified`() {
+ var wasNotified1: Boolean? = null
+ var wasNotified2: Boolean? = null
+
+ val callback1: (Boolean) -> Unit = { wasNotified1 = it }
+ val callback2: (Boolean) -> Unit = { wasNotified2 = it }
+
+ wifiConnectionMonitor.addOnWifiConnectedChangedListener(callback1)
+ wifiConnectionMonitor.addOnWifiConnectedChangedListener(callback2)
+ wifiConnectionMonitor.notifyListeners(true)
+
+ assertTrue(wasNotified1!!)
+ assertTrue(wasNotified2!!)
+ }
+
+ @Test
+ fun `GIVEN multiple listeners are are added and notify THEN a ConcurrentModificationException must not be thrown`() {
+ repeat(100) {
+ // Adding to callbacks.
+ wifiConnectionMonitor.addOnWifiConnectedChangedListener {
+ // Altering callbacks while looping.
+ if (wifiConnectionMonitor.callbacks.isNotEmpty()) {
+ wifiConnectionMonitor.callbacks.removeFirst()
+ }
+ }
+
+ // Looping over callbacks.
+ wifiConnectionMonitor.notifyListeners(true)
+ }
+ }
+}