// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.omnibox.suggestions.mostvisited;
import static androidx.test.espresso.matcher.ViewMatchers.assertThat;
import static org.hamcrest.Matchers.instanceOf;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.robolectric.Shadows.shadowOf;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.view.ContextThemeWrapper;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;
import org.chromium.base.Callback;
import org.chromium.base.ContextUtils;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.chrome.browser.omnibox.styles.OmniboxImageSupplier;
import org.chromium.chrome.browser.omnibox.styles.OmniboxResourceProvider;
import org.chromium.chrome.browser.omnibox.suggestions.SuggestionHost;
import org.chromium.chrome.browser.omnibox.suggestions.carousel.BaseCarouselSuggestionItemViewBuilder;
import org.chromium.chrome.browser.omnibox.suggestions.carousel.BaseCarouselSuggestionViewProperties;
import org.chromium.chrome.browser.omnibox.test.R;
import org.chromium.components.browser_ui.widget.tile.TileViewProperties;
import org.chromium.components.omnibox.AutocompleteMatch;
import org.chromium.components.omnibox.AutocompleteMatchBuilder;
import org.chromium.components.omnibox.OmniboxSuggestionType;
import org.chromium.components.omnibox.suggestions.OmniboxSuggestionUiType;
import org.chromium.ui.modelutil.MVCListAdapter.ListItem;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.url.GURL;
import org.chromium.url.JUnitTestGURLs;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/** Tests for {@link MostVisitedTilesProcessor}. */
@RunWith(BaseRobolectricTestRunner.class)
public final class MostVisitedTilesProcessorUnitTest {
private static final GURL NAV_URL = JUnitTestGURLs.URL_1;
private static final GURL NAV_URL_2 = JUnitTestGURLs.URL_2;
private static final GURL SEARCH_URL = JUnitTestGURLs.SEARCH_URL;
public @Rule MockitoRule mockitoRule = MockitoJUnit.rule();
private Context mContext;
private PropertyModel mPropertyModel;
private MostVisitedTilesProcessor mProcessor;
private List<AutocompleteMatch> mMatches;
private ArgumentCaptor<Callback<Bitmap>> mFavIconCallbackCaptor =
ArgumentCaptor.forClass(Callback.class);
private ArgumentCaptor<Callback<Bitmap>> mGenIconCallbackCaptor =
ArgumentCaptor.forClass(Callback.class);
private @Mock Bitmap mFaviconBitmap;
private @Mock SuggestionHost mSuggestionHost;
private @Mock OmniboxImageSupplier mImageSupplier;
static class TileData {
public final String title;
public final GURL url;
public final boolean isSearch;
public TileData(String title, GURL url, boolean isSearch) {
this.title = title;
this.url = url;
this.isSearch = isSearch;
}
}
@Before
public void setUp() {
mContext =
new ContextThemeWrapper(
ContextUtils.getApplicationContext(), R.style.Theme_BrowserUI_DayNight);
lenient()
.doNothing()
.when(mImageSupplier)
.fetchFavicon(any(), mFavIconCallbackCaptor.capture());
lenient()
.doNothing()
.when(mImageSupplier)
.generateFavicon(any(), mGenIconCallbackCaptor.capture());
mProcessor =
new MostVisitedTilesProcessor(
mContext, mSuggestionHost, Optional.of(mImageSupplier));
OmniboxResourceProvider.disableCachesForTesting();
}
@After
public void tearDown() {
OmniboxResourceProvider.reenableCachesForTesting();
}
/**
* @param tiles List of tiles that should be presented to the Processor
* @return Collection of ListItems describing type and properties of each TileView.
*/
private List<ListItem> populateMatchesForHorizontalRenderGroup(
int placement, TileData... tiles) {
mPropertyModel = mProcessor.createModel();
mMatches = new ArrayList<>();
for (int index = 0; index < tiles.length; index++) {
var tile = tiles[index];
var match =
new AutocompleteMatchBuilder(
tile.isSearch
? OmniboxSuggestionType.TILE_REPEATABLE_QUERY
: OmniboxSuggestionType.TILE_MOST_VISITED_SITE)
.setIsSearch(tile.isSearch)
.setDisplayText(tile.title)
.setUrl(tile.url)
.build();
mProcessor.populateModel(match, mPropertyModel, placement);
mMatches.add(match);
}
var resultingTiles = mPropertyModel.get(BaseCarouselSuggestionViewProperties.TILES);
assertEquals(tiles.length, resultingTiles.size());
return resultingTiles;
}
@Test
public void createModel_padding() {
var model = mProcessor.createModel();
assertEquals(
mContext.getResources()
.getDimensionPixelSize(R.dimen.omnibox_carousel_suggestion_padding_smaller),
model.get(BaseCarouselSuggestionViewProperties.TOP_PADDING));
assertEquals(
mContext.getResources()
.getDimensionPixelSize(R.dimen.omnibox_carousel_suggestion_padding),
model.get(BaseCarouselSuggestionViewProperties.BOTTOM_PADDING));
}
@Test
public void createModel_carouselBackground() {
var model = mProcessor.createModel();
assertFalse(model.get(BaseCarouselSuggestionViewProperties.APPLY_BACKGROUND));
}
@Test
public void populateModel_searchTile() {
List<ListItem> tileList =
populateMatchesForHorizontalRenderGroup(0, new TileData("title", SEARCH_URL, true));
verifyNoMoreInteractions(mImageSupplier);
ListItem tileItem = tileList.get(0);
PropertyModel tileModel = tileItem.model;
assertEquals("title", tileModel.get(TileViewProperties.TITLE));
assertEquals(BaseCarouselSuggestionItemViewBuilder.ViewType.TILE_VIEW, tileItem.type);
assertEquals(
R.drawable.ic_suggestion_magnifier,
shadowOf(tileModel.get(TileViewProperties.ICON)).getCreatedFromResId());
}
@Test
public void populateModel_navTileIcon_fallbackIcon() {
mProcessor =
new MostVisitedTilesProcessor(
mContext, mSuggestionHost, /* imageSupplier= */ Optional.empty());
List<ListItem> tileList =
populateMatchesForHorizontalRenderGroup(0, new TileData("title", NAV_URL, false));
verifyNoMoreInteractions(mImageSupplier);
ListItem tileItem = tileList.get(0);
PropertyModel tileModel = tileItem.model;
Drawable drawable = tileModel.get(TileViewProperties.ICON);
assertEquals(R.drawable.ic_globe_24dp, shadowOf(drawable).getCreatedFromResId());
}
@Test
public void populateModel_navTileIcon_favIcon() {
List<ListItem> tileList =
populateMatchesForHorizontalRenderGroup(0, new TileData("title", NAV_URL, false));
verify(mImageSupplier, times(1)).fetchFavicon(eq(NAV_URL), any());
mFavIconCallbackCaptor.getValue().onResult(mFaviconBitmap);
verifyNoMoreInteractions(mImageSupplier);
// Since we "retrieved" an icon from LargeIconBridge, we should not generate a fallback.
ListItem tileItem = tileList.get(0);
PropertyModel tileModel = tileItem.model;
Drawable drawable = tileModel.get(TileViewProperties.ICON);
assertEquals(BaseCarouselSuggestionItemViewBuilder.ViewType.TILE_VIEW, tileItem.type);
assertThat(drawable, instanceOf(BitmapDrawable.class));
Bitmap bitmap = ((BitmapDrawable) drawable).getBitmap();
assertEquals(mFaviconBitmap, bitmap);
}
@Test
public void populateModel_navTileIcon_generatedBitmap() {
List<ListItem> tileList =
populateMatchesForHorizontalRenderGroup(0, new TileData("title", NAV_URL, false));
verify(mImageSupplier).fetchFavicon(eq(NAV_URL), any());
mFavIconCallbackCaptor.getValue().onResult(null);
// We should now observe a request to generate bitmap.
verify(mImageSupplier).generateFavicon(eq(NAV_URL), mFavIconCallbackCaptor.capture());
mFavIconCallbackCaptor.getValue().onResult(mFaviconBitmap);
verifyNoMoreInteractions(mImageSupplier);
// Since we "retrieved" an icon from LargeIconBridge, we should not generate a fallback.
ListItem tileItem = tileList.get(0);
PropertyModel tileModel = tileItem.model;
Drawable drawable = tileModel.get(TileViewProperties.ICON);
assertEquals(BaseCarouselSuggestionItemViewBuilder.ViewType.TILE_VIEW, tileItem.type);
assertThat(drawable, instanceOf(BitmapDrawable.class));
Bitmap bitmap = ((BitmapDrawable) drawable).getBitmap();
assertEquals(mFaviconBitmap, bitmap);
}
@Test
public void populateModel_navTileIcon_fallbackIconUsedWhenGeneratedBitmapFails() {
List<ListItem> tileList =
populateMatchesForHorizontalRenderGroup(0, new TileData("title", NAV_URL, false));
// Fail to retrieve a real favicon.
verify(mImageSupplier).fetchFavicon(eq(NAV_URL), any());
mFavIconCallbackCaptor.getValue().onResult(null);
// We should now observe a request to generate bitmap. Return null.
verify(mImageSupplier).generateFavicon(eq(NAV_URL), mFavIconCallbackCaptor.capture());
mFavIconCallbackCaptor.getValue().onResult(null);
verifyNoMoreInteractions(mImageSupplier);
// Since we failed all retrieve attempts, we should keep using fallback icons.
ListItem tileItem = tileList.get(0);
PropertyModel tileModel = tileItem.model;
Drawable drawable = tileModel.get(TileViewProperties.ICON);
assertEquals(BaseCarouselSuggestionItemViewBuilder.ViewType.TILE_VIEW, tileItem.type);
assertEquals(R.drawable.ic_globe_24dp, shadowOf(drawable).getCreatedFromResId());
}
@Test
public void populateModel_navTileTitle_withMatchDescription() {
List<ListItem> tileList =
populateMatchesForHorizontalRenderGroup(0, new TileData("title", NAV_URL, false));
assertEquals("title", tileList.get(0).model.get(TileViewProperties.TITLE));
tileList =
populateMatchesForHorizontalRenderGroup(0, new TileData("title", NAV_URL, false));
assertEquals("title", tileList.get(0).model.get(TileViewProperties.TITLE));
}
@Test
public void populateModel_navTileTitle_withoutMatchDescriptionUsesHostName() {
List<ListItem> tileList =
populateMatchesForHorizontalRenderGroup(0, new TileData("", NAV_URL, false));
assertEquals(NAV_URL.getHost(), tileList.get(0).model.get(TileViewProperties.TITLE));
tileList = populateMatchesForHorizontalRenderGroup(0, new TileData("", NAV_URL, false));
assertEquals(NAV_URL.getHost(), tileList.get(0).model.get(TileViewProperties.TITLE));
}
@Test
public void testInteractions_onClick() {
List<ListItem> tileList =
populateMatchesForHorizontalRenderGroup(
3,
new TileData("search1", SEARCH_URL, true),
new TileData("nav1", NAV_URL, false),
new TileData("nav2", NAV_URL_2, false));
InOrder ordered = inOrder(mSuggestionHost);
// Simulate tile clicks.
// Note that the value being passed to the suggestion host denotes position of the Carousel
// on the list, rather than placement of the tile.
tileList.get(1).model.get(TileViewProperties.ON_CLICK).onClick(null);
ordered.verify(mSuggestionHost, times(1))
.onSuggestionClicked(eq(mMatches.get(1)), eq(3), eq(NAV_URL));
tileList.get(2).model.get(TileViewProperties.ON_CLICK).onClick(null);
ordered.verify(mSuggestionHost, times(1))
.onSuggestionClicked(eq(mMatches.get(2)), eq(3), eq(NAV_URL_2));
tileList.get(0).model.get(TileViewProperties.ON_CLICK).onClick(null);
ordered.verify(mSuggestionHost, times(1))
.onSuggestionClicked(eq(mMatches.get(0)), eq(3), eq(SEARCH_URL));
verifyNoMoreInteractions(mSuggestionHost);
// Verify histogram increased for delete attempt.
assertEquals(
1,
RecordHistogram.getHistogramValueCountForTesting(
"Omnibox.SuggestTiles.SelectedTileType", SuggestTileType.SEARCH));
assertEquals(
2,
RecordHistogram.getHistogramValueCountForTesting(
"Omnibox.SuggestTiles.SelectedTileType", SuggestTileType.URL));
assertEquals(
1,
RecordHistogram.getHistogramValueCountForTesting(
"Omnibox.SuggestTiles.SelectedTileIndex", 0));
assertEquals(
1,
RecordHistogram.getHistogramValueCountForTesting(
"Omnibox.SuggestTiles.SelectedTileIndex", 1));
assertEquals(
1,
RecordHistogram.getHistogramValueCountForTesting(
"Omnibox.SuggestTiles.SelectedTileIndex", 2));
}
@Test
public void testInteractions_onClick_horizontalGroup() {
List<ListItem> tileList =
populateMatchesForHorizontalRenderGroup(
3,
new TileData("search1", SEARCH_URL, true),
new TileData("nav1", NAV_URL, false),
new TileData("nav2", NAV_URL_2, false));
InOrder ordered = inOrder(mSuggestionHost);
// Simulate tile clicks.
// Note that the value being passed to the suggestion host denotes position of the Carousel
// on the list, rather than placement of the tile.
tileList.get(1).model.get(TileViewProperties.ON_CLICK).onClick(null);
ordered.verify(mSuggestionHost, times(1))
.onSuggestionClicked(eq(mMatches.get(1)), eq(3), eq(NAV_URL));
tileList.get(2).model.get(TileViewProperties.ON_CLICK).onClick(null);
ordered.verify(mSuggestionHost, times(1))
.onSuggestionClicked(eq(mMatches.get(2)), eq(3), eq(NAV_URL_2));
tileList.get(0).model.get(TileViewProperties.ON_CLICK).onClick(null);
ordered.verify(mSuggestionHost, times(1))
.onSuggestionClicked(eq(mMatches.get(0)), eq(3), eq(SEARCH_URL));
verifyNoMoreInteractions(mSuggestionHost);
// Verify histogram increased for delete attempt.
assertEquals(
1,
RecordHistogram.getHistogramValueCountForTesting(
"Omnibox.SuggestTiles.SelectedTileType", SuggestTileType.SEARCH));
assertEquals(
2,
RecordHistogram.getHistogramValueCountForTesting(
"Omnibox.SuggestTiles.SelectedTileType", SuggestTileType.URL));
assertEquals(
1,
RecordHistogram.getHistogramValueCountForTesting(
"Omnibox.SuggestTiles.SelectedTileIndex", 0));
assertEquals(
1,
RecordHistogram.getHistogramValueCountForTesting(
"Omnibox.SuggestTiles.SelectedTileIndex", 1));
assertEquals(
1,
RecordHistogram.getHistogramValueCountForTesting(
"Omnibox.SuggestTiles.SelectedTileIndex", 2));
}
@Test
public void testInteractions_onLongClick() {
List<ListItem> tileList =
populateMatchesForHorizontalRenderGroup(
1,
new TileData("search1", SEARCH_URL, true),
new TileData("nav1", NAV_URL, true),
new TileData("nav2", NAV_URL_2, true));
InOrder ordered = inOrder(mSuggestionHost);
// Simulate tile long-clicks.
// Note that this passes both placement of the carousel in the list as well as particular
// element that is getting removed.
tileList.get(1).model.get(TileViewProperties.ON_LONG_CLICK).onLongClick(null);
ordered.verify(mSuggestionHost, times(1)).onDeleteMatch(eq(mMatches.get(1)), eq("nav1"));
tileList.get(2).model.get(TileViewProperties.ON_LONG_CLICK).onLongClick(null);
ordered.verify(mSuggestionHost, times(1)).onDeleteMatch(eq(mMatches.get(2)), eq("nav2"));
tileList.get(0).model.get(TileViewProperties.ON_LONG_CLICK).onLongClick(null);
ordered.verify(mSuggestionHost, times(1)).onDeleteMatch(eq(mMatches.get(0)), eq("search1"));
verifyNoMoreInteractions(mSuggestionHost);
verifyNoMoreInteractions(mImageSupplier);
}
@Test
public void testInteractions_onLongClick_horizontalGroup() {
List<ListItem> tileList =
populateMatchesForHorizontalRenderGroup(
1,
new TileData("search1", SEARCH_URL, true),
new TileData("nav1", NAV_URL, true),
new TileData("nav2", NAV_URL_2, true));
InOrder ordered = inOrder(mSuggestionHost);
// Simulate tile long-clicks.
// Note that this passes both placement of the carousel in the list as well as particular
// element that is getting removed.
tileList.get(1).model.get(TileViewProperties.ON_LONG_CLICK).onLongClick(null);
ordered.verify(mSuggestionHost).onDeleteMatch(eq(mMatches.get(1)), eq("nav1"));
tileList.get(2).model.get(TileViewProperties.ON_LONG_CLICK).onLongClick(null);
ordered.verify(mSuggestionHost).onDeleteMatch(eq(mMatches.get(2)), eq("nav2"));
tileList.get(0).model.get(TileViewProperties.ON_LONG_CLICK).onLongClick(null);
ordered.verify(mSuggestionHost).onDeleteMatch(eq(mMatches.get(0)), eq("search1"));
verifyNoMoreInteractions(mSuggestionHost);
verifyNoMoreInteractions(mImageSupplier);
}
@Test
public void testInteractions_movingFocus() {
List<ListItem> tileList =
populateMatchesForHorizontalRenderGroup(
1,
new TileData("search1", SEARCH_URL, true),
new TileData("nav1", NAV_URL, true),
new TileData("nav2", NAV_URL_2, true));
InOrder ordered = inOrder(mSuggestionHost);
// Simulate navigation between the tiles. Expect the signal to be passed back to the
// suggestions host, describing what should be shown in the Omnibox.
tileList.get(1).model.get(TileViewProperties.ON_FOCUS_VIA_SELECTION).run();
ordered.verify(mSuggestionHost, times(1)).setOmniboxEditingText(eq(NAV_URL.getSpec()));
tileList.get(2).model.get(TileViewProperties.ON_FOCUS_VIA_SELECTION).run();
ordered.verify(mSuggestionHost, times(1)).setOmniboxEditingText(eq(NAV_URL_2.getSpec()));
tileList.get(0).model.get(TileViewProperties.ON_FOCUS_VIA_SELECTION).run();
ordered.verify(mSuggestionHost, times(1)).setOmniboxEditingText(eq(SEARCH_URL.getSpec()));
verifyNoMoreInteractions(mSuggestionHost);
verifyNoMoreInteractions(mImageSupplier);
}
@Test
public void testAccessibility_searchTile() {
List<ListItem> tileList =
populateMatchesForHorizontalRenderGroup(0, new TileData("title", SEARCH_URL, true));
ListItem tileItem = tileList.get(0);
PropertyModel tileModel = tileItem.model;
// Expect the search string in announcement.
String expectedDescription =
mContext.getString(
R.string.accessibility_omnibox_most_visited_tile_search, "title");
assertEquals(expectedDescription, tileModel.get(TileViewProperties.CONTENT_DESCRIPTION));
}
@Test
public void testAccessibility_navTile() {
List<ListItem> tileList =
populateMatchesForHorizontalRenderGroup(0, new TileData("title", NAV_URL, false));
ListItem tileItem = tileList.get(0);
PropertyModel tileModel = tileItem.model;
// Expect the navigation string in announcement.
String expectedDescription =
mContext.getString(
R.string.accessibility_omnibox_most_visited_tile_navigate,
"title",
NAV_URL.getHost());
assertEquals(expectedDescription, tileModel.get(TileViewProperties.CONTENT_DESCRIPTION));
}
@Test
public void testDescriptionWrapping_singleLine() {
List<ListItem> tileList =
populateMatchesForHorizontalRenderGroup(0, new TileData("title", NAV_URL, false));
ListItem tileItem = tileList.get(0);
PropertyModel tileModel = tileItem.model;
assertEquals(1, tileModel.get(TileViewProperties.TITLE_LINES));
}
@Test
public void doesProcessSuggestion_checkSupportedSuggestionTypes() {
var supportedTypes =
Set.of(
OmniboxSuggestionType.TILE_MOST_VISITED_SITE,
OmniboxSuggestionType.TILE_REPEATABLE_QUERY);
for (@OmniboxSuggestionType int type = 0; type < OmniboxSuggestionType.NUM_TYPES; type++) {
var match = AutocompleteMatchBuilder.searchWithType(type).build();
assertEquals(supportedTypes.contains(type), mProcessor.doesProcessSuggestion(match, 0));
}
}
@Test
public void getViewTypeId_forFullTestCoverage() {
assertEquals(OmniboxSuggestionUiType.TILE_NAVSUGGEST, mProcessor.getViewTypeId());
}
@Test
public void getCarouselItemViewHeight() {
// Consider using TileView directly.
int baseHeight =
mContext.getResources().getDimensionPixelSize(R.dimen.tile_view_min_height);
assertEquals(baseHeight, mProcessor.getCarouselItemViewHeight());
}
@Test
public void createModel_checkContentDescription() {
populateMatchesForHorizontalRenderGroup(0, new TileData("", SEARCH_URL, true));
assertEquals(
mContext.getResources().getString(R.string.accessibility_omnibox_most_visited_list),
mPropertyModel.get(BaseCarouselSuggestionViewProperties.CONTENT_DESCRIPTION));
}
}