chromium/content/public/android/javatests/src/org/chromium/content/browser/input/TextSuggestionMenuTest.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.content.browser.input;

import android.text.SpannableString;
import android.text.Spanned;
import android.text.style.SuggestionSpan;
import android.view.View;
import android.widget.ListView;
import android.widget.TextView;

import androidx.test.filters.LargeTest;

import org.hamcrest.Matchers;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

import org.chromium.base.ThreadUtils;
import org.chromium.base.test.util.Batch;
import org.chromium.base.test.util.CommandLineFlags;
import org.chromium.base.test.util.Criteria;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.base.test.util.CriteriaNotSatisfiedException;
import org.chromium.base.test.util.DisabledTest;
import org.chromium.content.R;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.browser.test.ContentJUnit4ClassRunner;
import org.chromium.content_public.browser.test.util.DOMUtils;
import org.chromium.content_public.browser.test.util.JavaScriptUtils;
import org.chromium.content_public.browser.test.util.TouchCommon;

import java.util.concurrent.TimeoutException;

/** Integration tests for the text suggestion menu. */
@RunWith(ContentJUnit4ClassRunner.class)
@CommandLineFlags.Add({"expose-internals-for-testing"})
@Batch(ImeTest.IME_BATCH)
public class TextSuggestionMenuTest {
    private static final String URL =
            "data:text/html, <div contenteditable id=\"div\" /><span id=\"span\" />";

    @Rule public ImeActivityTestRule mRule = new ImeActivityTestRule();

    @Before
    public void setUp() throws Throwable {
        mRule.setUpForUrl(ImeActivityTestRule.INPUT_FORM_HTML);
        mRule.fullyLoadUrl(URL);
    }

    @After
    public void tearDown() throws Exception {
        mRule.getActivity().finish();
    }

    @Test
    @LargeTest
    @DisabledTest(message = "https://crbug.com/1156419")
    public void testDeleteWordMarkedWithSuggestionMarker()
            throws InterruptedException, Throwable, TimeoutException {
        WebContents webContents = mRule.getWebContents();

        DOMUtils.focusNode(webContents, "div");

        SpannableString textToCommit = new SpannableString("hello");
        SuggestionSpan suggestionSpan =
                new SuggestionSpan(
                        mRule.getActivity(),
                        new String[] {"goodbye"},
                        SuggestionSpan.FLAG_EASY_CORRECT);
        textToCommit.setSpan(suggestionSpan, 0, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        mRule.commitText(textToCommit, 1);

        DOMUtils.clickNode(webContents, "div");
        waitForMenuToShow(webContents);

        TouchCommon.singleClickView(getDeleteButton(webContents));

        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    try {
                        Criteria.checkThat(
                                DOMUtils.getNodeContents(webContents, "div"),
                                Matchers.isEmptyString());
                    } catch (TimeoutException e) {
                        throw new CriteriaNotSatisfiedException(e);
                    }
                });

        waitForMenuToHide(webContents);
    }

    @Test
    @LargeTest
    public void testDeleteWordMarkedWithSpellingMarker()
            throws InterruptedException, Throwable, TimeoutException {
        WebContents webContents = mRule.getWebContents();

        DOMUtils.focusNode(webContents, "div");

        SpannableString textToCommit = new SpannableString("hello");
        mRule.commitText(textToCommit, 1);

        // Wait for renderer to acknowledge commitText(). ImeActivityTestRule.commitText() blocks
        // and waits for the IME thread to finish, but the communication between the IME thread and
        // the renderer is asynchronous, so if we try to run JavaScript right away, the text won't
        // necessarily have been committed yet.
        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    try {
                        Criteria.checkThat(
                                DOMUtils.getNodeContents(webContents, "div"), Matchers.is("hello"));
                    } catch (TimeoutException e) {
                        throw new CriteriaNotSatisfiedException(e);
                    }
                });

        // Add a spelling marker on "hello".
        JavaScriptUtils.executeJavaScriptAndWaitForResult(
                webContents,
                "const div = document.getElementById('div');"
                        + "const text = div.firstChild;"
                        + "const range = document.createRange();"
                        + "range.setStart(text, 0);"
                        + "range.setEnd(text, 5);"
                        + "internals.setMarker(document, range, 'spelling');");

        DOMUtils.clickNode(webContents, "div");
        waitForMenuToShow(webContents);

        TouchCommon.singleClickView(getDeleteButton(webContents));

        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    try {
                        Criteria.checkThat(
                                DOMUtils.getNodeContents(mRule.getWebContents(), "div"),
                                Matchers.isEmptyString());
                    } catch (TimeoutException e) {
                        throw new CriteriaNotSatisfiedException(e);
                    }
                });

        waitForMenuToHide(webContents);
    }

    @Test
    @LargeTest
    @DisabledTest(message = "https://crbug.com/1348267")
    public void testApplySuggestion() throws InterruptedException, Throwable, TimeoutException {
        WebContents webContents = mRule.getWebContents();

        DOMUtils.focusNode(webContents, "div");

        // We have a string of length 11 and we set three SuggestionSpans on it
        // to test that the menu shows the right suggestions in the right order:
        //
        // - One span on the word "hello"
        // - One span on the whole string "hello world"
        // - One span on the word "world"
        //
        // We simulate a tap at the end of the string. We should get the
        // suggestions from "world", then the suggestions from "hello world",
        // and not get any suggestions from "hello".

        SpannableString textToCommit = new SpannableString("hello world");

        SuggestionSpan suggestionSpan1 =
                new SuggestionSpan(
                        mRule.getActivity(),
                        new String[] {"invalid_suggestion"},
                        SuggestionSpan.FLAG_EASY_CORRECT);
        textToCommit.setSpan(suggestionSpan1, 0, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

        SuggestionSpan suggestionSpan2 =
                new SuggestionSpan(
                        mRule.getActivity(),
                        new String[] {"suggestion3", "suggestion4"},
                        SuggestionSpan.FLAG_EASY_CORRECT);
        textToCommit.setSpan(suggestionSpan2, 0, 11, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

        SuggestionSpan suggestionSpan3 =
                new SuggestionSpan(
                        mRule.getActivity(),
                        new String[] {"suggestion1", "suggestion2"},
                        SuggestionSpan.FLAG_EASY_CORRECT);
        textToCommit.setSpan(suggestionSpan3, 6, 11, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

        mRule.commitText(textToCommit, 1);

        // Wait for renderer to acknowledge commitText().
        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    try {
                        Criteria.checkThat(
                                DOMUtils.getNodeContents(webContents, "div"),
                                Matchers.is("hello world"));
                    } catch (TimeoutException e) {
                        throw new CriteriaNotSatisfiedException(e);
                    }
                });

        DOMUtils.clickNode(webContents, "span");
        waitForMenuToShow(webContents);

        // There should be 5 child views: 4 suggestions plus the list footer.
        Assert.assertEquals(5, getSuggestionList(webContents).getChildCount());

        Assert.assertEquals(
                "hello suggestion1",
                ((TextView) getSuggestionButton(webContents, 0)).getText().toString());
        Assert.assertEquals(
                "hello suggestion2",
                ((TextView) getSuggestionButton(webContents, 1)).getText().toString());
        Assert.assertEquals(
                "suggestion3",
                ((TextView) getSuggestionButton(webContents, 2)).getText().toString());
        Assert.assertEquals(
                "suggestion4",
                ((TextView) getSuggestionButton(webContents, 3)).getText().toString());

        TouchCommon.singleClickView(getSuggestionButton(webContents, 2));

        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    try {
                        Criteria.checkThat(
                                DOMUtils.getNodeContents(mRule.getWebContents(), "div"),
                                Matchers.is("suggestion3"));
                    } catch (TimeoutException e) {
                        throw new CriteriaNotSatisfiedException(e);
                    }
                });

        waitForMenuToHide(webContents);
    }

    @Test
    @LargeTest
    public void testApplyMisspellingSuggestion()
            throws InterruptedException, Throwable, TimeoutException {
        WebContents webContents = mRule.getWebContents();

        DOMUtils.focusNode(webContents, "div");

        SpannableString textToCommit = new SpannableString("word");

        SuggestionSpan suggestionSpan =
                new SuggestionSpan(
                        mRule.getActivity(),
                        new String[] {"replacement"},
                        SuggestionSpan.FLAG_EASY_CORRECT | SuggestionSpan.FLAG_MISSPELLED);
        textToCommit.setSpan(suggestionSpan, 0, 4, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

        mRule.commitText(textToCommit, 1);

        DOMUtils.clickNode(webContents, "span");
        waitForMenuToShow(webContents);

        // There should be 2 child views: 1 suggestion plus the list footer.
        Assert.assertEquals(2, getSuggestionList(webContents).getChildCount());

        Assert.assertEquals(
                "replacement",
                ((TextView) getSuggestionButton(webContents, 0)).getText().toString());

        TouchCommon.singleClickView(getSuggestionButton(webContents, 0));

        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    try {
                        Criteria.checkThat(
                                DOMUtils.getNodeContents(mRule.getWebContents(), "div"),
                                Matchers.is("replacement"));
                    } catch (TimeoutException e) {
                        throw new CriteriaNotSatisfiedException(e);
                    }
                });

        waitForMenuToHide(webContents);

        // Verify that the suggestion marker was replaced.
        Assert.assertEquals(
                "0",
                JavaScriptUtils.executeJavaScriptAndWaitForResult(
                        webContents,
                        "internals.markerCountForNode("
                                + "  document.getElementById('div').firstChild, 'suggestion')"));
    }

    @Test
    @LargeTest
    @DisabledTest(message = "https://crbug.com/1156419")
    public void suggestionMenuDismissal() throws InterruptedException, Throwable, TimeoutException {
        WebContents webContents = mRule.getWebContents();

        DOMUtils.focusNode(webContents, "div");

        SpannableString textToCommit = new SpannableString("hello");
        SuggestionSpan suggestionSpan =
                new SuggestionSpan(
                        mRule.getActivity(),
                        new String[] {"goodbye"},
                        SuggestionSpan.FLAG_EASY_CORRECT);
        textToCommit.setSpan(suggestionSpan, 0, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        mRule.commitText(textToCommit, 1);

        DOMUtils.clickNode(webContents, "div");
        waitForMenuToShow(webContents);

        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    getTextSuggestionHost(webContents)
                            .getTextSuggestionsPopupWindowForTesting()
                            .dismiss();
                });
        waitForMenuToHide(webContents);
    }

    @Test
    @LargeTest
    public void testAutoCorrectionSuggestionSpan() throws InterruptedException, Throwable {
        WebContents webContents = mRule.getWebContents();

        DOMUtils.focusNode(webContents, "div");
        mRule.waitAndVerifyUpdateSelection(0, 0, 0, -1, -1);

        SpannableString textToCommit = new SpannableString("hello");
        SuggestionSpan suggestionSpan =
                new SuggestionSpan(
                        mRule.getActivity(), new String[0], SuggestionSpan.FLAG_AUTO_CORRECTION);
        textToCommit.setSpan(suggestionSpan, 0, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        mRule.commitText(textToCommit, 1);
        mRule.waitAndVerifyUpdateSelection(1, 5, 5, -1, -1);

        Assert.assertEquals(
                "1",
                JavaScriptUtils.executeJavaScriptAndWaitForResult(
                        webContents,
                        "internals.markerCountForNode("
                                + "document.getElementById('div').firstChild, 'suggestion')"));
    }

    // The following 3 tests (test*RemovesAutoCorrectionSuggestionSpan()) are testing if we
    // correctly removed SuggestionSpan with SPAN_COMPOSING flag. If IME sets the SPAN_COMPOSING
    // flag for the span, the SuggestionSpan is in transition state, and we should remove it once we
    // done with composing.
    @Test
    @LargeTest
    public void testSetComposingTextRemovesAutoCorrectionSuggestionSpan()
            throws InterruptedException, Throwable {
        WebContents webContents = mRule.getWebContents();

        DOMUtils.focusNode(webContents, "div");
        mRule.waitAndVerifyUpdateSelection(0, 0, 0, -1, -1);

        SpannableString composingText = new SpannableString("hello");
        SuggestionSpan suggestionSpan =
                new SuggestionSpan(
                        mRule.getActivity(), new String[0], SuggestionSpan.FLAG_AUTO_CORRECTION);
        composingText.setSpan(
                suggestionSpan, 0, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | Spanned.SPAN_COMPOSING);
        mRule.setComposingText(composingText, 1);
        mRule.waitAndVerifyUpdateSelection(1, 5, 5, 0, 5);

        Assert.assertEquals(
                "1",
                JavaScriptUtils.executeJavaScriptAndWaitForResult(
                        webContents,
                        "internals.markerCountForNode("
                                + "document.getElementById('div').firstChild, 'suggestion')"));

        // setComposingText() will replace the text in current composing range and set a new
        // composing range, so the spans associated with composing range should be removed. If there
        // is no new span attached to the SpannableString, we should get 0 marker.
        mRule.setComposingText(new SpannableString("helloworld"), 1);
        mRule.waitAndVerifyUpdateSelection(2, 10, 10, 0, 10);

        Assert.assertEquals(
                "0",
                JavaScriptUtils.executeJavaScriptAndWaitForResult(
                        webContents,
                        "internals.markerCountForNode("
                                + "document.getElementById('div').firstChild, 'suggestion')"));
    }

    @Test
    @LargeTest
    public void testCommitTextRemovesAutoCorrectionSuggestionSpan()
            throws InterruptedException, Throwable {
        WebContents webContents = mRule.getWebContents();

        DOMUtils.focusNode(webContents, "div");
        mRule.waitAndVerifyUpdateSelection(0, 0, 0, -1, -1);

        SpannableString composingText = new SpannableString("hello");
        SuggestionSpan suggestionSpan =
                new SuggestionSpan(
                        mRule.getActivity(), new String[0], SuggestionSpan.FLAG_AUTO_CORRECTION);
        composingText.setSpan(
                suggestionSpan, 0, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | Spanned.SPAN_COMPOSING);
        mRule.setComposingText(composingText, 1);
        mRule.waitAndVerifyUpdateSelection(1, 5, 5, 0, 5);

        Assert.assertEquals(
                "1",
                JavaScriptUtils.executeJavaScriptAndWaitForResult(
                        webContents,
                        "internals.markerCountForNode("
                                + "document.getElementById('div').firstChild, 'suggestion')"));

        // commitText() will replace the text in current composing range and there won't be a new
        // composing range. So we done with composing and the SuggestionSpan with SPAN_COMPOSING
        // should be removed.
        mRule.commitText(new SpannableString("helloworld"), 1);
        mRule.waitAndVerifyUpdateSelection(2, 10, 10, -1, -1);

        Assert.assertEquals(
                "0",
                JavaScriptUtils.executeJavaScriptAndWaitForResult(
                        webContents,
                        "internals.markerCountForNode("
                                + "document.getElementById('div').firstChild, 'suggestion')"));
    }

    @Test
    @LargeTest
    public void testFinishComposingRemovesAutoCorrectionSuggestionSpan()
            throws InterruptedException, Throwable {
        WebContents webContents = mRule.getWebContents();

        DOMUtils.focusNode(webContents, "div");
        mRule.waitAndVerifyUpdateSelection(0, 0, 0, -1, -1);

        SpannableString composingText = new SpannableString("hello");
        SuggestionSpan suggestionSpan =
                new SuggestionSpan(
                        mRule.getActivity(), new String[0], SuggestionSpan.FLAG_AUTO_CORRECTION);
        composingText.setSpan(
                suggestionSpan, 0, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | Spanned.SPAN_COMPOSING);
        mRule.setComposingText(composingText, 1);
        mRule.waitAndVerifyUpdateSelection(1, 5, 5, 0, 5);

        Assert.assertEquals(
                "1",
                JavaScriptUtils.executeJavaScriptAndWaitForResult(
                        webContents,
                        "internals.markerCountForNode("
                                + "document.getElementById('div').firstChild, 'suggestion')"));

        // finishComposingText() will remove the composing range, any span has SPAN_COMPOSING flag
        // should be removed since there is no composing range available.
        mRule.finishComposingText();
        mRule.waitAndVerifyUpdateSelection(2, 5, 5, -1, -1);

        Assert.assertEquals(
                "0",
                JavaScriptUtils.executeJavaScriptAndWaitForResult(
                        webContents,
                        "internals.markerCountForNode("
                                + "document.getElementById('div').firstChild, 'suggestion')"));
    }

    private void waitForMenuToShow(WebContents webContents) {
        CriteriaHelper.pollUiThread(
                () -> {
                    View deleteButton = getDeleteButton(webContents);
                    Criteria.checkThat(deleteButton, Matchers.notNullValue());

                    // suggestionsPopupWindow.isShowing() returns true, the delete button hasn't
                    // been measured yet and getWidth()/getHeight() return 0. This causes the menu
                    // button click to instead fall on the "Add to dictionary" button. So we have
                    // to check that this isn't happening.
                    Criteria.checkThat(deleteButton.getWidth(), Matchers.not(0));
                });
    }

    private void waitForMenuToHide(WebContents webContents) {
        CriteriaHelper.pollUiThread(
                () -> {
                    SuggestionsPopupWindow suggestionsPopupWindow =
                            getTextSuggestionHost(webContents)
                                    .getTextSuggestionsPopupWindowForTesting();
                    Criteria.checkThat(suggestionsPopupWindow, Matchers.nullValue());

                    SuggestionsPopupWindow spellCheckPopupWindow =
                            getTextSuggestionHost(webContents).getSpellCheckPopupWindowForTesting();
                    Criteria.checkThat(spellCheckPopupWindow, Matchers.nullValue());
                });
    }

    private View getContentView(WebContents webContents) {
        SuggestionsPopupWindow suggestionsPopupWindow =
                getTextSuggestionHost(webContents).getTextSuggestionsPopupWindowForTesting();

        if (suggestionsPopupWindow != null) {
            return suggestionsPopupWindow.getContentViewForTesting();
        }

        SuggestionsPopupWindow spellCheckPopupWindow =
                getTextSuggestionHost(webContents).getSpellCheckPopupWindowForTesting();

        if (spellCheckPopupWindow != null) {
            return spellCheckPopupWindow.getContentViewForTesting();
        }

        return null;
    }

    private TextSuggestionHost getTextSuggestionHost(WebContents webContents) {
        return ThreadUtils.runOnUiThreadBlocking(
                () -> TextSuggestionHost.fromWebContents(webContents));
    }

    private ListView getSuggestionList(WebContents webContents) {
        View contentView = getContentView(webContents);
        return (ListView) contentView.findViewById(R.id.suggestionContainer);
    }

    private View getSuggestionButton(WebContents webContents, int suggestionIndex) {
        return getSuggestionList(webContents).getChildAt(suggestionIndex);
    }

    private View getDeleteButton(WebContents webContents) {
        View contentView = getContentView(webContents);
        if (contentView == null) {
            return null;
        }

        return contentView.findViewById(R.id.deleteButton);
    }
}