chromium/chrome/browser/ui/android/omnibox/java/src/org/chromium/chrome/browser/omnibox/suggestions/base/ActionChipsProcessorUnitTest.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.base;

import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;

import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;

import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.base.test.util.HistogramWatcher;
import org.chromium.base.test.util.JniMocker;
import org.chromium.chrome.browser.omnibox.OmniboxMetrics;
import org.chromium.chrome.browser.omnibox.R;
import org.chromium.chrome.browser.omnibox.suggestions.SuggestionHost;
import org.chromium.components.browser_ui.widget.chips.ChipProperties;
import org.chromium.components.omnibox.AutocompleteMatchBuilder;
import org.chromium.components.omnibox.OmniboxSuggestionType;
import org.chromium.components.omnibox.action.OmniboxAction;
import org.chromium.components.omnibox.action.OmniboxActionDelegate;
import org.chromium.components.omnibox.action.OmniboxActionId;
import org.chromium.components.omnibox.action.OmniboxActionJni;
import org.chromium.ui.modelutil.MVCListAdapter.ModelList;
import org.chromium.ui.modelutil.PropertyModel;

import java.util.List;

/** Tests for {@link ActionChipsProcessor}. */
@RunWith(BaseRobolectricTestRunner.class)
public class ActionChipsProcessorUnitTest {
    private static final int MATCH_POS = 1234;

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

    private @Mock OmniboxActionJni mOmniboxActionJni;
    private @Mock SuggestionHost mSuggestionHost;

    private ActionChipsProcessor mProcessor;
    private PropertyModel mModel;
    private ModelList mActionModel;

    @Before
    public void setUp() {
        mJniMocker.mock(OmniboxActionJni.TEST_HOOKS, mOmniboxActionJni);

        mProcessor = new ActionChipsProcessor(mSuggestionHost);
        mModel = new PropertyModel(ActionChipsProperties.ALL_UNIQUE_KEYS);
    }

    /**
     * Create an instance of OmniboxAction associated with specific native handle.
     *
     * @param handle the native handle to associate the instance with. 0 indicates invalid action.
     */
    private OmniboxAction actionWithHandle(long handle) {
        return actionWithHandleAndTextAppearance(handle, R.style.TextAppearance_ChipText);
    }

    private OmniboxAction actionWithHandleAndTextAppearance(long handle, int textAppearance) {
        return new OmniboxAction(
                OmniboxActionId.ACTION_IN_SUGGEST,
                handle,
                "hint",
                "accessibility hint",
                OmniboxAction.DEFAULT_ICON,
                textAppearance) {
            @Override
            public void execute(OmniboxActionDelegate delegate) {}
        };
    }

    /** Create a suggestion with supplied OmniboxActions (if any) and populate model. */
    private void populateModelForActions(OmniboxAction... actions) {
        var match =
                AutocompleteMatchBuilder.searchWithType(OmniboxSuggestionType.SEARCH_WHAT_YOU_TYPED)
                        .setActions(List.of(actions))
                        .build();
        mProcessor.populateModel(match, mModel, MATCH_POS);
        mActionModel = mModel.get(ActionChipsProperties.ACTION_CHIPS);
    }

    /** Simulate focus lost; confirm no more histograms are recorded. */
    private void verifyNoFollowUpRecords() {
        clearInvocations(mOmniboxActionJni);
        var watcher =
                HistogramWatcher.newBuilder()
                        .expectNoRecords(OmniboxMetrics.HISTOGRAM_OMNIBOX_ACTION_USED)
                        .expectNoRecords(OmniboxMetrics.HISTOGRAM_OMNIBOX_ACTION_VALID)
                        .build();
        mProcessor.onOmniboxSessionStateChange(false);
        watcher.assertExpected();
        verifyNoMoreInteractions(mOmniboxActionJni);
    }

    @Test
    public void onOmniboxSessionStateChange_noRecordsEverOnActivation() {
        // This is a perfectly normal scenario. Simulate that we have a suggestion with actions, we
        // click on one action, and then emit a "focus" signal. There should be NO uma records.
        populateModelForActions(actionWithHandle(1), actionWithHandle(/* handle= */ 0));
        assertEquals(2, mActionModel.size());
        mActionModel.get(0).model.get(ChipProperties.CLICK_HANDLER).onResult(/* model= */ null);

        var watcher =
                HistogramWatcher.newBuilder()
                        .expectNoRecords(OmniboxMetrics.HISTOGRAM_OMNIBOX_ACTION_USED)
                        .expectNoRecords(OmniboxMetrics.HISTOGRAM_OMNIBOX_ACTION_VALID)
                        .build();
        mProcessor.onOmniboxSessionStateChange(true);
        watcher.assertExpected();
        verifyNoMoreInteractions(mOmniboxActionJni);
    }

    @Test
    public void onOmniboxSessionStateChange_noRecordsWhenNoActionsWereAvailable() {
        populateModelForActions(/* no actions */ );
        verifyNoFollowUpRecords();
    }

    @Test
    public void onOmniboxSessionStateChange_recordNoUsage() {
        populateModelForActions(actionWithHandle(1));

        HistogramWatcher histogramWatcher =
                HistogramWatcher.newBuilder()
                        .expectBooleanRecord(OmniboxMetrics.HISTOGRAM_OMNIBOX_ACTION_USED, false)
                        .build();

        // Finish interaction.
        mProcessor.onOmniboxSessionStateChange(false);
        verify(mOmniboxActionJni).recordActionShown(1L, MATCH_POS, false);
        verifyNoMoreInteractions(mOmniboxActionJni);
        histogramWatcher.assertExpected();

        verifyNoFollowUpRecords();
    }

    @Test
    public void onOmniboxSessionStateChange_recordUsage() {
        populateModelForActions(actionWithHandle(1));
        HistogramWatcher histogramWatcher =
                HistogramWatcher.newBuilder()
                        .expectBooleanRecord(OmniboxMetrics.HISTOGRAM_OMNIBOX_ACTION_USED, true)
                        .build();

        // Click!
        assertEquals(1, mActionModel.size());
        mActionModel.get(0).model.get(ChipProperties.CLICK_HANDLER).onResult(/* model= */ null);
        mProcessor.onOmniboxSessionStateChange(false);

        verify(mOmniboxActionJni).recordActionShown(1L, MATCH_POS, /* used= */ true);
        histogramWatcher.assertExpected();

        verifyNoFollowUpRecords();
    }

    @Test
    public void onOmniboxSessionStateChange_recordActionValid() {
        populateModelForActions(actionWithHandle(1), actionWithHandle(2));
        HistogramWatcher histogramWatcher =
                HistogramWatcher.newBuilder()
                        .expectBooleanRecordTimes(
                                OmniboxMetrics.HISTOGRAM_OMNIBOX_ACTION_VALID, true, 2)
                        .build();

        mProcessor.onOmniboxSessionStateChange(false);

        verify(mOmniboxActionJni).recordActionShown(1L, MATCH_POS, false);
        verify(mOmniboxActionJni).recordActionShown(2L, MATCH_POS, false);
        verifyNoMoreInteractions(mOmniboxActionJni);
        histogramWatcher.assertExpected();

        verifyNoFollowUpRecords();
    }

    @Test
    public void onOmniboxSessionStateChange_recordActionNotValidAfterDestroyCalled() {
        var action1 = actionWithHandle(1);
        var action2 = actionWithHandle(2);
        populateModelForActions(action1, action2);

        action1.destroy();

        HistogramWatcher histogramWatcher =
                HistogramWatcher.newBuilder()
                        .expectBooleanRecordTimes(
                                OmniboxMetrics.HISTOGRAM_OMNIBOX_ACTION_VALID, true, 1)
                        .expectBooleanRecordTimes(
                                OmniboxMetrics.HISTOGRAM_OMNIBOX_ACTION_VALID, false, 1)
                        .build();

        mProcessor.onOmniboxSessionStateChange(false);

        verify(mOmniboxActionJni).recordActionShown(2L, MATCH_POS, false);
        verifyNoMoreInteractions(mOmniboxActionJni);
        histogramWatcher.assertExpected();

        verifyNoFollowUpRecords();
    }

    @Test
    public void onOmniboxSessionStateChange_actionsFromMultipleMatchesAreAggregated() {
        // Three matches. 2 valid, 1 invalid.
        // Note that typically all the ModelLists are associated with individual PropertyModels, but
        // our tests use just a single PropertyModel, so these ModelLists get replaced.
        populateModelForActions(actionWithHandle(1), actionWithHandle(2));
        populateModelForActions(actionWithHandle(3), actionWithHandle(4));
        populateModelForActions(actionWithHandle(0), actionWithHandle(0));

        // Only the latest ModelList is available (previos two have been overwritten).
        assertEquals(2, mActionModel.size());
        mActionModel.get(0).model.get(ChipProperties.CLICK_HANDLER).onResult(/* model= */ null);

        HistogramWatcher histogramWatcher =
                HistogramWatcher.newBuilder()
                        .expectBooleanRecordTimes(
                                OmniboxMetrics.HISTOGRAM_OMNIBOX_ACTION_VALID, true, 4)
                        .expectBooleanRecordTimes(
                                OmniboxMetrics.HISTOGRAM_OMNIBOX_ACTION_VALID, false, 2)
                        .build();

        mProcessor.onOmniboxSessionStateChange(false);

        verify(mOmniboxActionJni).recordActionShown(1L, MATCH_POS, false);
        verify(mOmniboxActionJni).recordActionShown(2L, MATCH_POS, false);
        verify(mOmniboxActionJni).recordActionShown(3L, MATCH_POS, false);
        verify(mOmniboxActionJni).recordActionShown(4L, MATCH_POS, false);
        verifyNoMoreInteractions(mOmniboxActionJni);
        histogramWatcher.assertExpected();

        verifyNoFollowUpRecords();
    }

    @Test
    public void onOmniboxSessionStateChange_recordActionInvalid() {
        populateModelForActions(actionWithHandle(1), actionWithHandle(/* handle= */ 0));
        HistogramWatcher histogramWatcher =
                HistogramWatcher.newBuilder()
                        .expectBooleanRecordTimes(
                                OmniboxMetrics.HISTOGRAM_OMNIBOX_ACTION_VALID, true, 1)
                        .expectBooleanRecordTimes(
                                OmniboxMetrics.HISTOGRAM_OMNIBOX_ACTION_VALID, false, 1)
                        .build();

        mProcessor.onOmniboxSessionStateChange(false);
        verify(mOmniboxActionJni).recordActionShown(1L, MATCH_POS, false);
        histogramWatcher.assertExpected();

        verifyNoFollowUpRecords();
    }

    @Test
    public void onSuggestionsReceived_resetsLastState() {
        populateModelForActions(actionWithHandle(1), actionWithHandle(/* handle= */ 0));
        HistogramWatcher histogramWatcher =
                HistogramWatcher.newBuilder()
                        .expectBooleanRecordTimes(
                                OmniboxMetrics.HISTOGRAM_OMNIBOX_ACTION_VALID, true, 1)
                        .expectBooleanRecordTimes(
                                OmniboxMetrics.HISTOGRAM_OMNIBOX_ACTION_VALID, false, 1)
                        .build();

        // Simulate new set of suggestions.
        mProcessor.onSuggestionsReceived();
        verifyNoFollowUpRecords();
    }

    @Test
    public void chipTextAppearance() {
        populateModelForActions(
                actionWithHandleAndTextAppearance(1, R.style.TextAppearance_ChipText),
                actionWithHandleAndTextAppearance(
                        2, R.style.TextAppearance_TextMediumThick_Primary_Baseline));

        ModelList chipModel = mModel.get(ActionChipsProperties.ACTION_CHIPS);
        assertEquals(
                R.style.TextAppearance_ChipText,
                chipModel.get(0).model.get(ChipProperties.PRIMARY_TEXT_APPEARANCE));
        assertEquals(
                R.style.TextAppearance_TextMediumThick_Primary_Baseline,
                chipModel.get(1).model.get(ChipProperties.PRIMARY_TEXT_APPEARANCE));
    }
}