chromium/chrome/browser/ui/android/omnibox/java/src/org/chromium/chrome/browser/omnibox/SpannableAutocompleteEditTextModelUnitTest.java

// Copyright 2017 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;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.anyInt;
import static org.mockito.Mockito.atLeast;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;

import android.content.Context;
import android.text.SpannableStringBuilder;
import android.view.ContextThemeWrapper;
import android.view.KeyEvent;

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.ContextUtils;
import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.chrome.browser.omnibox.test.R;

import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;

/** Unit tests for {@link SpannableAutocompleteEditTextModel}. */
@RunWith(BaseRobolectricTestRunner.class)
public class SpannableAutocompleteEditTextModelUnitTest {
    public @Rule MockitoRule mockitoRule = MockitoJUnit.rule();
    private @Mock AutocompleteInputConnection mConnection;
    private @Mock AutocompleteEditTextModelBase.Delegate mDelegate;
    private SpannableAutocompleteEditTextModel mModel;
    private AutocompleteState mCurrentState;
    private AtomicInteger mImeCommandNestLevel;

    @Before
    public void setUp() {
        Context context =
                new ContextThemeWrapper(
                        ContextUtils.getApplicationContext(), R.style.Theme_BrowserUI_DayNight);
        doReturn(new SpannableStringBuilder("text")).when(mDelegate).getText();
        mModel = new SpannableAutocompleteEditTextModel(mDelegate, context);
        mModel.setInputConnectionForTesting(mConnection);
        mImeCommandNestLevel = new AtomicInteger();
        mCurrentState = mModel.getCurrentAutocompleteState();
        clearInvocations(mDelegate);

        doAnswer(
                        inv -> {
                            return mImeCommandNestLevel.incrementAndGet() != 0;
                        })
                .when(mConnection)
                .onBeginImeCommand();

        doAnswer(
                        inv -> {
                            return mImeCommandNestLevel.decrementAndGet() == 0;
                        })
                .when(mConnection)
                .onEndImeCommand();
    }

    @Test
    public void testNonCompositionalText() {
        assertTrue(SpannableAutocompleteEditTextModel.isNonCompositionalText("http://123.com"));
        assertTrue(SpannableAutocompleteEditTextModel.isNonCompositionalText("goo"));
        assertFalse(SpannableAutocompleteEditTextModel.isNonCompositionalText("네이버"));
        assertFalse(SpannableAutocompleteEditTextModel.isNonCompositionalText("네"));
        assertFalse(SpannableAutocompleteEditTextModel.isNonCompositionalText("123네이버"));
    }

    // Dispatch the key code and check that it committed the autocomplete suggestion without
    // dispatching the key event to the delegate.
    private void confirmAutocompletionApplied(int keyCode) {
        var event = new KeyEvent(KeyEvent.ACTION_DOWN, keyCode);

        clearInvocations(mConnection, mDelegate);
        mModel.dispatchKeyEvent(event);
        verify(mDelegate, times(0)).super_dispatchKeyEvent(event);
        verify(mConnection).commitAutocomplete();

        // Secondary, not directly linked to the test.
        verify(mConnection, atLeastOnce()).onBeginImeCommand();
        verify(mConnection, atLeastOnce()).onEndImeCommand();
        assertEquals(0, mImeCommandNestLevel.get());
        verifyNoMoreInteractions(mConnection, mDelegate);
    }

    // Dispatch the key code and check that it committed the autocomplete suggestion but also
    // forwarded the key event to the delegate.
    private void confirmAutocompletionAppliedWithKey(int keyCode) {
        var event = new KeyEvent(KeyEvent.ACTION_DOWN, keyCode);

        clearInvocations(mConnection, mDelegate);
        mModel.dispatchKeyEvent(event);
        verify(mDelegate).super_dispatchKeyEvent(event);
        verify(mConnection).commitAutocomplete();

        // Allow the handler to implement the action possibly by setting the selection or not.
        verify(mDelegate, atLeast(0)).setSelection(anyInt(), anyInt());

        // Secondary, not directly linked to the test.
        verify(mConnection, atLeastOnce()).onBeginImeCommand();
        verify(mConnection, atLeastOnce()).onEndImeCommand();
        assertEquals(0, mImeCommandNestLevel.get());
        verifyNoMoreInteractions(mConnection, mDelegate);
    }

    // Dispatch the key code and check that the even was forwarded to the delegate without
    // committing the suggestion.
    private void confirmAutocompletionBypassed(int keyCode) {
        var event = new KeyEvent(KeyEvent.ACTION_DOWN, keyCode);

        clearInvocations(mConnection, mDelegate);
        mModel.dispatchKeyEvent(event);
        verify(mConnection, times(0)).commitAutocomplete();
        verify(mDelegate).super_dispatchKeyEvent(event);

        // Secondary, not directly linked to the test.
        verify(mConnection, atLeastOnce()).onBeginImeCommand();
        verify(mConnection, atLeastOnce()).onEndImeCommand();
        assertEquals(0, mImeCommandNestLevel.get());
        verifyNoMoreInteractions(mConnection, mDelegate);
    }

    @Test
    public void dispatchKeyEvent_processAutocompleteKeysWhenAutocompletionIsAvailable() {
        mCurrentState.setAutocompleteText(Optional.of("google.com"));

        confirmAutocompletionAppliedWithKey(KeyEvent.KEYCODE_DPAD_RIGHT);
        // Enter is forwarded to the delegate for handling which is what "bypassed" checks.
        confirmAutocompletionBypassed(KeyEvent.KEYCODE_ENTER);
        confirmAutocompletionApplied(KeyEvent.KEYCODE_TAB);
        confirmAutocompletionAppliedWithKey(KeyEvent.KEYCODE_DPAD_LEFT);
    }

    @Test
    public void dispatchKeyEvent_passAutocompleteKeysWhenAutocompletionIsNotAvailable() {
        mCurrentState.setAutocompleteText(Optional.empty());

        confirmAutocompletionBypassed(KeyEvent.KEYCODE_DPAD_RIGHT);
        confirmAutocompletionBypassed(KeyEvent.KEYCODE_ENTER);
        confirmAutocompletionBypassed(KeyEvent.KEYCODE_TAB);
        confirmAutocompletionBypassed(KeyEvent.KEYCODE_DPAD_LEFT);
    }

    @Test
    public void dispatchKeyEvent_handleForwardDel() {
        mCurrentState.setUserText("goo");
        mCurrentState.setAutocompleteText(Optional.of("gle.com"));
        assertEquals(mCurrentState.getText(), "google.com"); // Verify full state constructed.

        // The delete key doesn't get sent to our delegate when in autocomplete mode so
        // confirmAutocompletionBypassed() doesn't work. Manually dispatch.
        var event = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_FORWARD_DEL);
        clearInvocations(mConnection, mDelegate);
        mModel.dispatchKeyEvent(event);

        // Inline autocompleted text should be deleted.
        assertEquals(mCurrentState.getText(), "goo");

        // Go left and then forward delete the last user-char. The forward delete should still
        // get dispatched.
        confirmAutocompletionBypassed(KeyEvent.KEYCODE_DPAD_LEFT);
        confirmAutocompletionBypassed(KeyEvent.KEYCODE_FORWARD_DEL);
    }
}