mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SelectionActionDelegateTest.kt
author Ting-Yu Lin <tlin@mozilla.com>
Sat, 16 Mar 2019 07:38:34 +0000
changeset 522371 e19d324e5970979c11c697c600d60fc366314ed8
parent 492891 ef88efc93b440aa048a2be8764f46ffe26dd716e
child 527503 7137c336e6ea1f70b03a38891f05c45c4cea91e6
permissions -rw-r--r--
Bug 1493317 Part 2 - Enable AccessibleCaret in unit tests. r=jchen And use correct AccessibleCaret preference to disable it individually in tests. Differential Revision: https://phabricator.services.mozilla.com/D10299

/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
 * Any copyright is dedicated to the Public Domain.
   http://creativecommons.org/publicdomain/zero/1.0/ */

package org.mozilla.geckoview.test

import org.mozilla.geckoview.GeckoResponse
import org.mozilla.geckoview.GeckoSession
import org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.*
import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.NullDelegate
import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDevToolsAPI
import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
import org.mozilla.geckoview.test.util.Callbacks

import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.support.test.InstrumentationRegistry
import android.support.test.filters.MediumTest

import org.hamcrest.Matcher
import org.hamcrest.Matchers.*
import org.junit.Assume.assumeThat
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import org.junit.runners.Parameterized.Parameter
import org.junit.runners.Parameterized.Parameters

@MediumTest
@RunWith(Parameterized::class)
@WithDevToolsAPI
@WithDisplay(width = 100, height = 100)
class SelectionActionDelegateTest : BaseSessionTest() {
    enum class ContentType {
        DIV, EDITABLE_ELEMENT, IFRAME
    }

    companion object {
        @get:Parameters(name = "{0}")
        @JvmStatic
        val parameters: List<Array<out Any>> = listOf(
                arrayOf("#text", ContentType.DIV, "lorem", false),
                arrayOf("#input", ContentType.EDITABLE_ELEMENT, "ipsum", true),
                arrayOf("#textarea", ContentType.EDITABLE_ELEMENT, "dolor", true),
                arrayOf("#contenteditable", ContentType.DIV, "sit", true),
                arrayOf("#iframe", ContentType.IFRAME, "amet", false),
                arrayOf("#designmode", ContentType.IFRAME, "consectetur", true))
    }

    @field:Parameter(0) @JvmField var id: String = ""
    @field:Parameter(1) @JvmField var type: ContentType = ContentType.DIV
    @field:Parameter(2) @JvmField var initialContent: String = ""
    @field:Parameter(3) @JvmField var editable: Boolean = false

    private val selectedContent by lazy {
        when (type) {
            ContentType.DIV -> SelectedDiv(id, initialContent)
            ContentType.EDITABLE_ELEMENT -> SelectedEditableElement(id, initialContent)
            ContentType.IFRAME -> SelectedFrame(id, initialContent)
        }
    }

    private val collapsedContent by lazy {
        when (type) {
            ContentType.DIV -> CollapsedDiv(id)
            ContentType.EDITABLE_ELEMENT -> CollapsedEditableElement(id)
            ContentType.IFRAME -> CollapsedFrame(id)
        }
    }


    /** Generic tests for each content type. */

    @Test fun request() {
        if (editable) {
            withClipboard ("text") {
                testThat(selectedContent, {}, hasShowActionRequest(
                        FLAG_IS_EDITABLE, arrayOf(ACTION_COLLAPSE_TO_START, ACTION_COLLAPSE_TO_END,
                                                  ACTION_COPY, ACTION_CUT, ACTION_DELETE,
                                                  ACTION_HIDE, ACTION_PASTE, ACTION_SELECT_ALL)))
            }
        } else {
            testThat(selectedContent, {}, hasShowActionRequest(
                    0, arrayOf(ACTION_COPY, ACTION_HIDE, ACTION_SELECT_ALL,
                                           ACTION_UNSELECT)))
        }
    }

    @Test fun request_collapsed() = assumingEditable(true) {
        withClipboard ("text") {
            testThat(collapsedContent, {}, hasShowActionRequest(
                    FLAG_IS_EDITABLE or FLAG_IS_COLLAPSED,
                    arrayOf(ACTION_HIDE, ACTION_PASTE, ACTION_SELECT_ALL)))
        }
    }

    @Test fun request_noClipboard() = assumingEditable(true) {
        withClipboard("") {
            testThat(collapsedContent, {}, hasShowActionRequest(
                    FLAG_IS_EDITABLE or FLAG_IS_COLLAPSED,
                    arrayOf(ACTION_HIDE, ACTION_SELECT_ALL)))
        }
    }

    @Test fun hide() = testThat(selectedContent, withResponse(ACTION_HIDE), clearsSelection())

    @Test fun cut() = assumingEditable(true) {
        withClipboard("") {
            testThat(selectedContent, withResponse(ACTION_CUT), copiesText(), deletesContent())
        }
    }

    @Test fun copy() = withClipboard("") {
        testThat(selectedContent, withResponse(ACTION_COPY), copiesText())
    }

    @Test fun paste() = assumingEditable(true) {
        withClipboard("pasted") {
            testThat(selectedContent, withResponse(ACTION_PASTE), changesContentTo("pasted"))
        }
    }

    @Test fun delete() = assumingEditable(true) {
        testThat(selectedContent, withResponse(ACTION_DELETE), deletesContent())
    }

    @Test fun selectAll() {
        if (type == ContentType.DIV && !editable) {
            // "Select all" for non-editable div means selecting the whole document.
            testThat(selectedContent, withResponse(ACTION_SELECT_ALL), changesSelectionTo(
                    both(containsString(selectedContent.initialContent))
                            .and(not(equalTo(selectedContent.initialContent)))))
        } else {
            testThat(if (editable) collapsedContent else selectedContent,
                     withResponse(ACTION_SELECT_ALL),
                     changesSelectionTo(selectedContent.initialContent))
        }
    }

    @Test fun unselect() = assumingEditable(false) {
        testThat(selectedContent, withResponse(ACTION_UNSELECT), clearsSelection())
    }

    @Test fun multipleActions() = assumingEditable(false) {
        withClipboard("") {
            testThat(selectedContent, withResponse(ACTION_COPY, ACTION_UNSELECT),
                     copiesText(), clearsSelection())
        }
    }

    @Test fun collapseToStart() = assumingEditable(true) {
        testThat(selectedContent, withResponse(ACTION_COLLAPSE_TO_START), hasSelectionAt(0))
    }

    @Test fun collapseToEnd() = assumingEditable(true) {
        testThat(selectedContent, withResponse(ACTION_COLLAPSE_TO_END),
                 hasSelectionAt(selectedContent.initialContent.length))
    }

    @Test fun pagehide() {
        // Navigating to another page should hide selection action.
        testThat(selectedContent, { mainSession.loadTestPath(HELLO_HTML_PATH) }, clearsSelection())
    }

    @Test fun deactivate() {
        // Blurring the window should hide selection action.
        testThat(selectedContent, { mainSession.setFocused(false) }, clearsSelection())
        mainSession.setFocused(true)
    }

    @NullDelegate(GeckoSession.SelectionActionDelegate::class)
    @Test fun clearDelegate() {
        var counter = 0
        mainSession.selectionActionDelegate = object : Callbacks.SelectionActionDelegate {
            override fun onHideAction(session: GeckoSession, reason: Int) {
                counter++
            }
        }

        mainSession.selectionActionDelegate = null
        assertThat("Hide action should be called when clearing delegate",
                   counter, equalTo(1))
    }


    /** Interface that defines behavior for a particular type of content */
    private interface SelectedContent {
        fun focus() {}
        fun select() {}
        val initialContent: String
        val content: String
        val selectionOffsets: Pair<Int, Int>
    }

    /** Main method that performs test logic. */
    private fun testThat(content: SelectedContent,
                         respondingWith: (GeckoResponse<String>) -> Unit,
                         result: (SelectedContent) -> Unit,
                         vararg sideEffects: (SelectedContent) -> Unit) {

        mainSession.loadTestPath(INPUTS_PATH)
        mainSession.waitForPageStop()

        content.focus()

        // Show selection actions for collapsed selections, so we can test them.
        // Also, always show accessible carets / selection actions for changes due to JS calls.
        sessionRule.setPrefsUntilTestEnd(mapOf(
                "geckoview.selection_action.show_on_focus" to true,
                "layout.accessiblecaret.script_change_update_mode" to 2))

        mainSession.delegateDuringNextWait(object : Callbacks.SelectionActionDelegate {
            override fun onShowActionRequest(session: GeckoSession, selection: GeckoSession.SelectionActionDelegate.Selection, actions: Array<out String>, response: GeckoResponse<String>) {
                respondingWith(response)
            }
        })

        content.select()
        mainSession.waitUntilCalled(object : Callbacks.SelectionActionDelegate {
            @AssertCalled(count = 1)
            override fun onShowActionRequest(session: GeckoSession, selection: Selection, actions: Array<out String>, response: GeckoResponse<String>) {
                assertThat("Initial content should match",
                           selection.text, equalTo(content.initialContent))
            }
        })

        result(content)
        sideEffects.forEach { it(content) }
    }


    /** Helpers. */

    private val clipboard by lazy {
        InstrumentationRegistry.getTargetContext().getSystemService(Context.CLIPBOARD_SERVICE)
                as ClipboardManager
    }

    private fun withClipboard(content: String = "", lambda: () -> Unit) {
        val oldClip = clipboard.primaryClip
        try {
            clipboard.primaryClip = ClipData.newPlainText("", content)

            sessionRule.addExternalDelegateUntilTestEnd(
                    ClipboardManager.OnPrimaryClipChangedListener::class,
                    clipboard::addPrimaryClipChangedListener,
                    clipboard::removePrimaryClipChangedListener,
                    ClipboardManager.OnPrimaryClipChangedListener {})
            lambda()
        } finally {
            clipboard.primaryClip = oldClip ?: ClipData.newPlainText("", "")
        }
    }

    private fun assumingEditable(editable: Boolean, lambda: (() -> Unit)? = null) {
        assumeThat("Assuming is ${if (editable) "" else "not "}editable",
                   this.editable, equalTo(editable))
        lambda?.invoke()
    }


    /** Behavior objects for different content types */

    open inner class SelectedDiv(val id: String,
                                 override val initialContent: String) : SelectedContent {
        protected fun selectTo(to: Int) {
            mainSession.evaluateJS("""document.getSelection().setBaseAndExtent(
                $('$id').firstChild, 0, $('$id').firstChild, $to)""")
        }

        override fun select() = selectTo(initialContent.length)

        override val content: String get() {
            return mainSession.evaluateJS("$('$id').textContent") as String
        }

        override val selectionOffsets: Pair<Int, Int> get() {
            if (mainSession.evaluateJS("""
                document.getSelection().anchorNode !== $('$id').firstChild ||
                document.getSelection().focusNode !== $('$id').firstChild""") as Boolean) {
                return Pair(-1, -1)
            }
            val offsets = mainSession.evaluateJS("""[
                document.getSelection().anchorOffset,
                document.getSelection().focusOffset]""").asJSList<Double>()
            return Pair(offsets[0].toInt(), offsets[1].toInt())
        }
    }

    inner class CollapsedDiv(id: String) : SelectedDiv(id, "") {
        override fun select() = selectTo(0)
    }

    open inner class SelectedEditableElement(
            val id: String, override val initialContent: String) : SelectedContent {
        override fun focus() {
            mainSession.waitForJS("$('$id').focus()")
        }

        override fun select() {
            mainSession.evaluateJS("$('$id').select()")
        }

        override val content: String get() {
            return mainSession.evaluateJS("$('$id').value") as String
        }

        override val selectionOffsets: Pair<Int, Int> get() {
            val offsets = mainSession.evaluateJS(
                    "[ $('$id').selectionStart, $('$id').selectionEnd ]").asJSList<Double>()
            return Pair(offsets[0].toInt(), offsets[1].toInt())
        }
    }

    inner class CollapsedEditableElement(id: String) : SelectedEditableElement(id, "") {
        override fun select() {
            mainSession.evaluateJS("$('$id').setSelectionRange(0, 0)")
        }
    }

    open inner class SelectedFrame(val id: String,
                                   override val initialContent: String) : SelectedContent {
        override fun focus() {
            mainSession.evaluateJS("$('$id').contentWindow.focus()")
        }

        protected fun selectTo(to: Int) {
            mainSession.evaluateJS("""(function() {
                    var doc = $('$id').contentDocument;
                    var text = doc.body.firstChild;
                    doc.getSelection().setBaseAndExtent(text, 0, text, $to);
                })()""")
        }

        override fun select() = selectTo(initialContent.length)

        override val content: String get() {
            return mainSession.evaluateJS("$('$id').contentDocument.body.textContent") as String
        }

        override val selectionOffsets: Pair<Int, Int> get() {
            val offsets = mainSession.evaluateJS("""(function() {
                    var sel = $('$id').contentDocument.getSelection();
                    var text = $('$id').contentDocument.body.firstChild;
                    if (sel.anchorNode !== text || sel.focusNode !== text) {
                        return [-1, -1];
                    }
                    return [sel.anchorOffset, sel.focusOffset];
                })()""").asJSList<Double>()
            return Pair(offsets[0].toInt(), offsets[1].toInt())
        }
    }

    inner class CollapsedFrame(id: String) : SelectedFrame(id, "") {
        override fun select() = selectTo(0)
    }


    /** Lambda for responding with certain actions. */

    private fun withResponse(vararg actions: String): (GeckoResponse<String>) -> Unit {
        var responded = false
        return { response ->
            if (!responded) {
                responded = true
                actions.forEach { response.respond(it) }
            }
        }
    }


    /** Lambdas for asserting the results of actions. */

    private fun hasShowActionRequest(expectedFlags: Int,
                                     expectedActions: Array<out String>) = { it: SelectedContent ->
        mainSession.forCallbacksDuringWait(object : Callbacks.SelectionActionDelegate {
            @AssertCalled(count = 1)
            override fun onShowActionRequest(session: GeckoSession, selection: GeckoSession.SelectionActionDelegate.Selection, actions: Array<out String>, response: GeckoResponse<String>) {
                assertThat("Selection text should be valid",
                           selection.text, equalTo(it.initialContent))
                assertThat("Selection flags should be valid",
                           selection.flags, equalTo(expectedFlags))
                assertThat("Selection rect should be valid",
                           selection.clientRect.isEmpty, equalTo(false))
                assertThat("Actions must be valid", actions,
                           arrayContainingInAnyOrder(*expectedActions))
            }
        })
    }

    private fun copiesText() = { it: SelectedContent ->
        sessionRule.waitUntilCalled(ClipboardManager.OnPrimaryClipChangedListener {
            assertThat("Clipboard should contain correct text",
                       clipboard.primaryClip?.getItemAt(0)?.text,
                       hasToString(it.initialContent))
        })
    }

    private fun changesSelectionTo(text: String) = changesSelectionTo(equalTo(text))

    private fun changesSelectionTo(matcher: Matcher<String>) = { _: SelectedContent ->
        sessionRule.waitUntilCalled(object : Callbacks.SelectionActionDelegate {
            @AssertCalled(count = 1)
            override fun onShowActionRequest(session: GeckoSession, selection: Selection, actions: Array<out String>, response: GeckoResponse<String>) {
                assertThat("New selection text should match", selection.text, matcher)
            }
        })
    }

    private fun clearsSelection() = { _: SelectedContent ->
        sessionRule.waitUntilCalled(object : Callbacks.SelectionActionDelegate {
            @AssertCalled(count = 1)
            override fun onHideAction(session: GeckoSession, reason: Int) {
                assertThat("Hide reason should be correct",
                           reason, equalTo(HIDE_REASON_NO_SELECTION))
            }
        })
    }

    private fun hasSelectionAt(offset: Int) = hasSelectionAt(offset, offset)

    private fun hasSelectionAt(start: Int, end: Int) = { it: SelectedContent ->
        assertThat("Selection offsets should match",
                   it.selectionOffsets, equalTo(Pair(start, end)))
    }

    private fun deletesContent() = changesContentTo("")

    private fun changesContentTo(content: String) = { it: SelectedContent ->
        assertThat("Changed content should match", it.content, equalTo(content))
    }
}