chromium/chrome/browser/ui/android/omnibox/java/src/org/chromium/chrome/browser/omnibox/suggestions/editurl/EditUrlSuggestionProcessorUnitTest.java

// Copyright 2023 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.editurl;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;

import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;

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.Mock;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;
import org.robolectric.annotation.Config;
import org.robolectric.annotation.Implementation;
import org.robolectric.annotation.Implements;

import org.chromium.base.ContextUtils;
import org.chromium.base.supplier.Supplier;
import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.base.test.util.JniMocker;
import org.chromium.base.test.util.UserActionTester;
import org.chromium.chrome.browser.omnibox.R;
import org.chromium.chrome.browser.omnibox.styles.OmniboxImageSupplier;
import org.chromium.chrome.browser.omnibox.suggestions.SuggestionHost;
import org.chromium.chrome.browser.omnibox.suggestions.base.BaseSuggestionViewProperties;
import org.chromium.chrome.browser.omnibox.suggestions.basic.SuggestionViewProperties;
import org.chromium.chrome.browser.share.ShareDelegate;
import org.chromium.chrome.browser.share.ShareDelegate.ShareOrigin;
import org.chromium.chrome.browser.tab.SadTab;
import org.chromium.chrome.browser.tab.Tab;
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.components.ukm.UkmRecorder;
import org.chromium.components.ukm.UkmRecorderJni;
import org.chromium.content_public.browser.WebContents;
import org.chromium.ui.base.Clipboard;
import org.chromium.ui.base.ClipboardImpl;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.url.GURL;
import org.chromium.url.JUnitTestGURLs;

import java.util.Optional;

/** Unit tests for the "edit url" omnibox suggestion. */
@RunWith(BaseRobolectricTestRunner.class)
@Config(shadows = {EditUrlSuggestionProcessorUnitTest.ShadowSadTab.class})
public final class EditUrlSuggestionProcessorUnitTest {
    private static final String TAB_TITLE = "Tab Title";
    private static final String MATCH_TITLE = "Match Title";
    private static final int ACTION_SHARE = 0;
    private static final int ACTION_COPY = 1;
    private static final int ACTION_EDIT = 2;
    private static final GURL SEARCH_URL_1 = JUnitTestGURLs.SEARCH_URL;
    private static final GURL SEARCH_URL_2 = JUnitTestGURLs.SEARCH_2_URL;
    private static final GURL INVALID_ESCAPED_PATH_URL =
            new GURL("https://pl.wikipedia.org/wiki/G%X");

    public static final String ESCAPED_PATH_URL_STRING = "https://pl.wikipedia.org/wiki/Gżegżółka";
    public static final GURL ESCAPED_PATH_URL =
            new GURL("https://pl.wikipedia.org/wiki/G%C5%BCeg%C5%BC%C3%B3%C5%82ka");

    /** Used to simulate sad tabs. */
    @Implements(SadTab.class)
    static class ShadowSadTab {
        public static boolean reportSadTab;

        @Implementation
        public static boolean isShowing(Tab t) {
            return reportSadTab;
        }
    }

    public @Rule JniMocker mJniMocker = new JniMocker();
    public @Rule MockitoRule mMockitoRule = MockitoJUnit.rule();

    private @Mock ShareDelegate mShareDelegate;
    private @Mock Tab mTab;
    private @Mock OmniboxImageSupplier mImageSupplier;
    private @Mock SuggestionHost mSuggestionHost;
    private @Mock ClipboardManager mClipboardManager;
    private @Mock WebContents mWebContents;
    private @Mock Supplier<Tab> mTabSupplier;
    private @Mock Supplier<ShareDelegate> mShareDelegateSupplier;
    private @Mock UkmRecorder.Natives mUkmRecorderJniMock;

    // The original (real) ClipboardManager to be restored after a test run.
    private Context mContext;
    private AutocompleteMatch mMatch;
    private ClipboardManager mOldClipboardManager;
    private EditUrlSuggestionProcessor mProcessor;
    private PropertyModel mModel;

    @Before
    public void setUp() {
        mJniMocker.mock(UkmRecorderJni.TEST_HOOKS, mUkmRecorderJniMock);

        mOldClipboardManager =
                ((ClipboardImpl) Clipboard.getInstance())
                        .overrideClipboardManagerForTesting(mClipboardManager);

        mContext = ContextUtils.getApplicationContext();
        mMatch =
                new AutocompleteMatchBuilder(OmniboxSuggestionType.URL_WHAT_YOU_TYPED)
                        .setIsSearch(false)
                        .setDisplayText(SEARCH_URL_1.getSpec())
                        .setDescription(MATCH_TITLE)
                        .setUrl(SEARCH_URL_1)
                        .build();

        mProcessor =
                new EditUrlSuggestionProcessor(
                        mContext,
                        mSuggestionHost,
                        Optional.of(mImageSupplier),
                        mTabSupplier,
                        mShareDelegateSupplier);
        mModel = mProcessor.createModel();

        doReturn(mTab).when(mTabSupplier).get();
        doReturn(mShareDelegate).when(mShareDelegateSupplier).get();
        doReturn(SEARCH_URL_1).when(mTab).getUrl();
        doReturn(TAB_TITLE).when(mTab).getTitle();
        doReturn(true).when(mTab).isInitialized();

        mProcessor.onOmniboxSessionStateChange(true);
    }

    @After
    public void tearDown() {
        mProcessor.onOmniboxSessionStateChange(false);
        ((ClipboardImpl) Clipboard.getInstance())
                .overrideClipboardManagerForTesting(mOldClipboardManager);
    }

    @Test
    public void doesProcessSuggestion_rejectNonMatchingUrlWhatYouTyped() {
        var match =
                new AutocompleteMatchBuilder(OmniboxSuggestionType.URL_WHAT_YOU_TYPED)
                        .setUrl(SEARCH_URL_1)
                        .build();

        // URLs don't match - this suggestion should be ignored.
        doReturn(SEARCH_URL_2).when(mTab).getUrl();
        assertFalse(mProcessor.doesProcessSuggestion(match, 0));
        verifyNoMoreInteractions(mSuggestionHost, mShareDelegate, mClipboardManager);
    }

    @Test
    public void doesProcessSuggestion_acceptMatchingUrlWhatYouTyped() {
        assertTrue(mProcessor.doesProcessSuggestion(mMatch, 0));
        verifyNoMoreInteractions(mSuggestionHost, mShareDelegate, mClipboardManager);
    }

    @Test
    public void doesProcessSuggestion_rejectMatchWhenTabIsMissing() {
        doReturn(null).when(mTabSupplier).get();
        assertFalse(mProcessor.doesProcessSuggestion(mMatch, 0));
        verifyNoMoreInteractions(mSuggestionHost, mShareDelegate, mClipboardManager);
    }

    @Test
    public void doesProcessSuggestion_rejectMatchForNativeTab() {
        doReturn(true).when(mTab).isNativePage();
        assertFalse(mProcessor.doesProcessSuggestion(mMatch, 0));
        verifyNoMoreInteractions(mSuggestionHost, mShareDelegate, mClipboardManager);
    }

    @Test
    public void doesProcessSuggestion_rejectMatchForSadTab() {
        ShadowSadTab.reportSadTab = true;
        assertFalse(mProcessor.doesProcessSuggestion(mMatch, 0));
        verifyNoMoreInteractions(mSuggestionHost, mShareDelegate, mClipboardManager);
    }

    @Test
    public void doesProcessSuggestion_rejectMatchForUninitializedTab() {
        doReturn(false).when(mTab).isInitialized();
        assertFalse(mProcessor.doesProcessSuggestion(mMatch, 0));
        verifyNoMoreInteractions(mSuggestionHost, mShareDelegate, mClipboardManager);
    }

    @Test
    public void doesProcessSuggestion_rejectNonTopMatch() {
        assertFalse(mProcessor.doesProcessSuggestion(mMatch, 1));
        verifyNoMoreInteractions(mSuggestionHost, mShareDelegate, mClipboardManager);
    }

    @Test
    public void doesProcessSuggestion_rejectSearchWhatYouTyped() {
        var match =
                new AutocompleteMatchBuilder(OmniboxSuggestionType.SEARCH_WHAT_YOU_TYPED)
                        .setUrl(SEARCH_URL_1)
                        .build();
        // Suggestion should be rejected even though URLs match.
        when(mTab.getUrl()).thenReturn(SEARCH_URL_1);
        assertFalse(mProcessor.doesProcessSuggestion(match, 0));
        verifyNoMoreInteractions(mSuggestionHost, mShareDelegate, mClipboardManager);
    }

    @Test
    public void populateModel_showInformationFromLoadedTab() {
        mProcessor.populateModel(mMatch, mModel, 0);

        assertEquals(3, mModel.get(BaseSuggestionViewProperties.ACTION_BUTTONS).size());
        assertEquals(TAB_TITLE, mModel.get(SuggestionViewProperties.TEXT_LINE_1_TEXT).toString());
        assertEquals(
                SEARCH_URL_1.getSpec(),
                mModel.get(SuggestionViewProperties.TEXT_LINE_2_TEXT).toString());
    }

    @Test
    public void populateModel_substituteMatchInformationForLoadingTab() {
        doReturn(true).when(mTab).isLoading();
        mProcessor.populateModel(mMatch, mModel, 0);
        assertEquals(MATCH_TITLE, mModel.get(SuggestionViewProperties.TEXT_LINE_1_TEXT).toString());
    }

    @Test
    public void populateModel_substituteFallbackInformationForLoadingTab() {
        mMatch =
                new AutocompleteMatchBuilder(OmniboxSuggestionType.URL_WHAT_YOU_TYPED)
                        .setDisplayText(SEARCH_URL_1.getSpec())
                        .setDescription("")
                        .setUrl(SEARCH_URL_1)
                        .build();
        doReturn(true).when(mTab).isLoading();
        mProcessor.populateModel(mMatch, mModel, 0);
        assertEquals(
                mContext.getResources().getText(R.string.tab_loading_default_title),
                mModel.get(SuggestionViewProperties.TEXT_LINE_1_TEXT).toString());
    }

    @Test
    public void shareButton_click() {
        mProcessor.populateModel(mMatch, mModel, 0);

        var monitor = new UserActionTester();
        mModel.get(BaseSuggestionViewProperties.ACTION_BUTTONS).get(ACTION_SHARE).callback.run();
        verify(mSuggestionHost).finishInteraction();
        verify(mShareDelegate, times(1))
                .share(mTab, /* shareDirectly= */ false, ShareOrigin.EDIT_URL);
        // Note: UkmRecorder requires WebContents to report metrics.
        // In the even WebContents is not available, we should not interact with UkmRecorder.
        verifyNoMoreInteractions(mUkmRecorderJniMock);

        assertEquals(1, monitor.getActionCount("Omnibox.EditUrlSuggestion.Share"));
        assertEquals(1, monitor.getActions().size());
        monitor.tearDown();
    }

    @Test
    public void shareButton_click_reportsUkmEvent() {
        doReturn(mWebContents).when(mTab).getWebContents();

        mProcessor.populateModel(mMatch, mModel, 0);
        mModel.get(BaseSuggestionViewProperties.ACTION_BUTTONS).get(ACTION_SHARE).callback.run();
        verify(mSuggestionHost).finishInteraction();

        verify(mShareDelegate).share(mTab, /* shareDirectly= */ false, ShareOrigin.EDIT_URL);
        verify(mUkmRecorderJniMock)
                .recordEventWithBooleanMetric(
                        any(), eq("Omnibox.EditUrlSuggestion.Share"), eq("HasOccurred"));
    }

    @Test
    public void suggestionView_clickReloadsPage() {
        mProcessor.populateModel(mMatch, mModel, 0);

        var monitor = new UserActionTester();
        mModel.get(BaseSuggestionViewProperties.ON_CLICK).run();
        verify(mSuggestionHost).onSuggestionClicked(mMatch, 0, mMatch.getUrl());
        verifyNoMoreInteractions(mSuggestionHost);

        assertEquals(1, monitor.getActionCount("Omnibox.EditUrlSuggestion.Tap"));
        assertEquals(1, monitor.getActions().size());
        monitor.tearDown();
    }

    @Test
    public void copyButton_click() {
        mProcessor.populateModel(mMatch, mModel, 0);
        var monitor = new UserActionTester();
        mModel.get(BaseSuggestionViewProperties.ACTION_BUTTONS).get(ACTION_COPY).callback.run();

        ArgumentCaptor<ClipData> argument = ArgumentCaptor.forClass(ClipData.class);
        verify(mClipboardManager, times(1)).setPrimaryClip(argument.capture());

        // ClipData doesn't implement equals, but their string representations matching should be
        // good enough.
        ClipData clip =
                new ClipData(
                        "url",
                        new String[] {"text/x-moz-url", "text/plain"},
                        new ClipData.Item(SEARCH_URL_1.getSpec()));
        assertEquals(clip.toString(), argument.getValue().toString());
        verifyNoMoreInteractions(mSuggestionHost, mShareDelegate, mClipboardManager);

        assertEquals(1, monitor.getActionCount("Omnibox.EditUrlSuggestion.Copy"));
        assertEquals(1, monitor.getActions().size());
        monitor.tearDown();
    }

    @Test
    public void getViewTypeId_forFullTestCoverage() {
        assertEquals(OmniboxSuggestionUiType.EDIT_URL_SUGGESTION, mProcessor.getViewTypeId());
    }
}