# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:

# Copyright 2014-2015 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser.  If not, see <http://www.gnu.org/licenses/>.

"""Tests for the webelement utils."""

from unittest import mock
import collections.abc
import operator
import itertools
import binascii
import os.path

import hypothesis
import hypothesis.strategies
from PyQt5.QtCore import PYQT_VERSION, QRect, QPoint
from PyQt5.QtWebKit import QWebElement
import pytest

from qutebrowser.browser import webelem


def get_webelem(geometry=None, frame=None, null=False, style=None,
                display='', attributes=None, tagname=None, classes=None):
    """Factory for WebElementWrapper objects based on a mock.

    Args:
        geometry: The geometry of the QWebElement as QRect.
        frame: The QWebFrame the element is in.
        null: Whether the element is null or not.
        style: A dict with the styleAttributes of the element.
        attributes: Boolean HTML attributes to be added.
        tagname: The tag name.
        classes: HTML classes to be added.
    """
    elem = mock.Mock()
    elem.isNull.return_value = null
    elem.geometry.return_value = geometry
    elem.webFrame.return_value = frame
    elem.tagName.return_value = tagname
    elem.toOuterXml.return_value = '<fakeelem/>'
    elem.toPlainText.return_value = 'text'

    attribute_dict = {}
    if attributes is None:
        pass
    elif not isinstance(attributes, collections.abc.Mapping):
        attribute_dict.update({e: None for e in attributes})
    else:
        attribute_dict.update(attributes)

    elem.hasAttribute.side_effect = lambda k: k in attribute_dict
    elem.attribute.side_effect = lambda k: attribute_dict.get(k, '')
    elem.setAttribute.side_effect = (lambda k, v:
        operator.setitem(attribute_dict, k, v))
    elem.removeAttribute.side_effect = attribute_dict.pop
    elem.attributeNames.return_value = list(attribute_dict)

    if classes is not None:
        elem.classes.return_value = classes.split(' ')
    else:
        elem.classes.return_value = []

    style_dict = {'visibility': '', 'display': ''}
    if style is not None:
        style_dict.update(style)

    def _style_property(name, strategy):
        """Helper function to act as styleProperty method."""
        if strategy != QWebElement.ComputedStyle:
            raise ValueError("styleProperty called with strategy != "
                             "ComputedStyle ({})!".format(strategy))
        return style_dict[name]

    elem.styleProperty.side_effect = _style_property
    wrapped = webelem.WebElementWrapper(elem)
    return wrapped


class SelectionAndFilterTests:

    """Generator for tests for TestSelectionsAndFilters."""

    # A mapping of a HTML element to a list of groups where the selectors
    # (after filtering) should match.
    #
    # Based on this, test cases are generated to make sure it matches those
    # groups and not the others.

    TESTS = [
        ('<foo />', []),
        ('<foo bar="baz"/>', []),
        ('<foo href="baz"/>', [webelem.Group.url]),
        ('<foo src="baz"/>', [webelem.Group.url]),

        ('<a />', [webelem.Group.all]),
        ('<a href="foo" />', [webelem.Group.all, webelem.Group.links,
                              webelem.Group.prevnext, webelem.Group.url]),
        ('<a href="javascript://foo" />', [webelem.Group.all,
                                          webelem.Group.url]),

        ('<area />', [webelem.Group.all]),
        ('<area href="foo" />', [webelem.Group.all, webelem.Group.links,
                                 webelem.Group.prevnext, webelem.Group.url]),
        ('<area href="javascript://foo" />', [webelem.Group.all,
                                              webelem.Group.url]),

        ('<link />', [webelem.Group.all]),
        ('<link href="foo" />', [webelem.Group.all, webelem.Group.links,
                                 webelem.Group.prevnext, webelem.Group.url]),
        ('<link href="javascript://foo" />', [webelem.Group.all,
                                              webelem.Group.url]),

        ('<textarea />', [webelem.Group.all]),
        ('<select />', [webelem.Group.all]),

        ('<input />', [webelem.Group.all]),
        ('<input type="hidden" />', []),

        ('<button />', [webelem.Group.all]),
        ('<button href="foo" />', [webelem.Group.all, webelem.Group.prevnext,
                                   webelem.Group.url]),
        ('<button href="javascript://foo" />', [webelem.Group.all,
                                                webelem.Group.url]),

        # We can't easily test <frame>/<iframe> as they vanish when setting
        # them via QWebFrame::setHtml...

        ('<p onclick="foo" foo="bar"/>', [webelem.Group.all]),
        ('<p onmousedown="foo" foo="bar"/>', [webelem.Group.all]),
        ('<p role="option" foo="bar"/>', [webelem.Group.all]),
        ('<p role="button" foo="bar"/>', [webelem.Group.all]),
        ('<p role="button" href="bar"/>', [webelem.Group.all,
                                           webelem.Group.prevnext,
                                           webelem.Group.url]),
    ]

    GROUPS = [e for e in webelem.Group if e != webelem.Group.focus]

    COMBINATIONS = list(itertools.product(TESTS, GROUPS))

    def __init__(self):
        self.tests = list(self._generate_tests())

    def _generate_tests(self):
        for (val, matching_groups), group in self.COMBINATIONS:
            if group in matching_groups:
                yield group, val, True
            else:
                yield group, val, False


class TestSelectorsAndFilters:

    TESTS = SelectionAndFilterTests().tests

    def test_test_generator(self):
        assert self.TESTS

    @pytest.mark.parametrize('group, val, matching', TESTS)
    def test_selectors(self, webframe, group, val, matching):
        webframe.setHtml('<html><body>{}</body></html>'.format(val))
        # Make sure setting HTML succeeded and there's a new element
        assert len(webframe.findAllElements('*')) == 3
        elems = webframe.findAllElements(webelem.SELECTORS[group])
        elems = [webelem.WebElementWrapper(e) for e in elems]
        filterfunc = webelem.FILTERS.get(group, lambda e: True)
        elems = [e for e in elems if filterfunc(e)]
        assert bool(elems) == matching


class TestWebElementWrapper:

    """Generic tests for WebElementWrapper.

    Note: For some methods, there's a dedicated test class with more involved
    tests.
    """

    @pytest.fixture
    def elem(self):
        return get_webelem()

    def test_nullelem(self):
        """Test __init__ with a null element."""
        with pytest.raises(webelem.IsNullError):
            get_webelem(null=True)

    def test_double_wrap(self, elem):
        """Test wrapping a WebElementWrapper."""
        with pytest.raises(TypeError) as excinfo:
            webelem.WebElementWrapper(elem)
        assert str(excinfo.value) == "Trying to wrap a wrapper!"

    @pytest.mark.parametrize('code', [
        str,
        lambda e: e[None],
        lambda e: operator.setitem(e, None, None),
        lambda e: operator.delitem(e, None),
        lambda e: None in e,
        len,
        lambda e: e.is_visible(None),
        lambda e: e.rect_on_view(),
        lambda e: e.is_writable(),
        lambda e: e.is_content_editable(),
        lambda e: e.is_editable(),
        lambda e: e.is_text_input(),
        lambda e: e.debug_text(),
        list,  # __iter__
    ])
    def test_vanished(self, elem, code):
        """Make sure methods check if the element is vanished."""
        elem._elem.isNull.return_value = True
        with pytest.raises(webelem.IsNullError):
            code(elem)

    def test_str(self, elem):
        assert str(elem) == 'text'

    @pytest.mark.parametrize('is_null, expected', [
        (False, "<qutebrowser.browser.webelem.WebElementWrapper "
                "html='<fakeelem/>'>"),
        (True, '<qutebrowser.browser.webelem.WebElementWrapper html=None>'),
    ])
    def test_repr(self, elem, is_null, expected):
        elem._elem.isNull.return_value = is_null
        assert repr(elem) == expected

    def test_getitem(self):
        elem = get_webelem(attributes={'foo': 'bar'})
        assert elem['foo'] == 'bar'

    def test_getitem_keyerror(self, elem):
        with pytest.raises(KeyError):
            elem['foo']  # pylint: disable=pointless-statement

    def test_setitem(self, elem):
        elem['foo'] = 'bar'
        assert elem._elem.attribute('foo') == 'bar'

    def test_delitem(self):
        elem = get_webelem(attributes={'foo': 'bar'})
        del elem['foo']
        assert not elem._elem.hasAttribute('foo')

    def test_setitem_keyerror(self, elem):
        with pytest.raises(KeyError):
            del elem['foo']

    def test_contains(self):
        elem = get_webelem(attributes={'foo': 'bar'})
        assert 'foo' in elem
        assert 'bar' not in elem

    @pytest.mark.parametrize('attributes, expected', [
        ({'one': '1', 'two': '2'}, {'one', 'two'}),
        ({}, set()),
    ])
    def test_iter(self, attributes, expected):
        elem = get_webelem(attributes=attributes)
        assert set(elem) == expected

    @pytest.mark.parametrize('attributes, length', [
        ({'one': '1', 'two': '2'}, 2),
        ({}, 0),
    ])
    def test_len(self, attributes, length):
        elem = get_webelem(attributes=attributes)
        assert len(elem) == length

    @pytest.mark.parametrize('attributes, writable', [
        ([], True),
        (['disabled'], False),
        (['readonly'], False),
        (['disabled', 'readonly'], False),
    ])
    def test_is_writable(self, attributes, writable):
        elem = get_webelem(attributes=attributes)
        assert elem.is_writable() == writable

    @pytest.mark.parametrize('attributes, expected', [
        ({}, False),
        ({'contenteditable': 'false'}, False),
        ({'contenteditable': 'inherit'}, False),
        ({'contenteditable': 'true'}, True),
    ])
    def test_is_content_editable(self, attributes, expected):
        elem = get_webelem(attributes=attributes)
        assert elem.is_content_editable() == expected

    @pytest.mark.parametrize('tagname, attributes, expected', [
        ('input', {}, True),
        ('textarea', {}, True),
        ('select', {}, False),
        ('foo', {'role': 'combobox'}, True),
        ('foo', {'role': 'textbox'}, True),
        ('foo', {'role': 'bar'}, False),
        ('input', {'role': 'bar'}, True),
    ])
    def test_is_text_input(self, tagname, attributes, expected):
        elem = get_webelem(tagname=tagname, attributes=attributes)
        assert elem.is_text_input() == expected

    @pytest.mark.parametrize('xml, expected', [
        ('<fakeelem/>', '<fakeelem/>'),
        ('<foo>\n<bar/>\n</foo>', '<foo><bar/></foo>'),
        ('<foo>{}</foo>'.format('x' * 500), '<foo>{}…'.format('x' * 494)),
    ], ids=['fakeelem', 'newlines', 'long'])
    def test_debug_text(self, elem, xml, expected):
        elem._elem.toOuterXml.return_value = xml
        assert elem.debug_text() == expected


class TestIsVisible:

    @pytest.fixture
    def frame(self, stubs):
        return stubs.FakeWebFrame(QRect(0, 0, 100, 100))

    def test_invalid_frame_geometry(self, stubs):
        """Test with an invalid frame geometry."""
        rect = QRect(0, 0, 0, 0)
        assert not rect.isValid()
        frame = stubs.FakeWebFrame(rect)
        elem = get_webelem(QRect(0, 0, 10, 10), frame)
        assert not elem.is_visible(frame)

    def test_invalid_invisible(self, frame):
        """Test elements with an invalid geometry which are invisible."""
        elem = get_webelem(QRect(0, 0, 0, 0), frame)
        assert not elem.geometry().isValid()
        assert elem.geometry().x() == 0
        assert not elem.is_visible(frame)

    def test_invalid_visible(self, frame):
        """Test elements with an invalid geometry which are visible.

        This seems to happen sometimes in the real world, with real elements
        which *are* visible, but don't have a valid geometry.
        """
        elem = get_webelem(QRect(10, 10, 0, 0), frame)
        assert not elem.geometry().isValid()
        assert elem.is_visible(frame)

    @pytest.mark.parametrize('geometry, visible', [
        (QRect(5, 5, 4, 4), False),
        (QRect(10, 10, 1, 1), True),
    ])
    def test_scrolled(self, geometry, visible, stubs):
        scrolled_frame = stubs.FakeWebFrame(QRect(0, 0, 100, 100),
                                            scroll=QPoint(10, 10))
        elem = get_webelem(geometry, scrolled_frame)
        assert elem.is_visible(scrolled_frame) == visible

    @pytest.mark.parametrize('style, visible', [
        ({'visibility': 'visible'}, True),
        ({'visibility': 'hidden'}, False),
        ({'display': 'inline'}, True),
        ({'display': 'none'}, False),
        ({'visibility': 'visible', 'display': 'none'}, False),
        ({'visibility': 'hidden', 'display': 'inline'}, False),
    ])
    def test_css_attributes(self, frame, style, visible):
        elem = get_webelem(QRect(0, 0, 10, 10), frame, style=style)
        assert elem.is_visible(frame) == visible


class TestIsVisibleIframe:

    """Tests for is_visible with a child frame.

    Attributes:
        frame: The FakeWebFrame we're using to test.
        iframe: The iframe inside frame.
        elem1-elem4: FakeWebElements to test.
    """

    Objects = collections.namedtuple('Objects', ['frame', 'iframe', 'elems'])

    @pytest.fixture
    def objects(self, stubs):
        """Set up the following base situation.

             0, 0                         300, 0
              ##############################
              #                            #
         0,10 # iframe  100,10             #
              #**********                  #
              #*e       * elems[0]: 0, 0 in iframe (visible)
              #*        *                  #
              #* e      * elems[1]: 20,90 in iframe (visible)
              #**********                  #
        0,110 #.        .100,110           #
              #.        .                  #
              #. e      . elems[2]: 20,150 in iframe (not visible)
              #..........                  #
              #     e     elems[3]: 30, 180 in main frame (visible)
              #                            #
              #          frame             #
              ##############################
            300, 0                         300, 300

        Returns an Objects namedtuple with frame/iframe/elems attributes.
        """
        frame = stubs.FakeWebFrame(QRect(0, 0, 300, 300))
        iframe = stubs.FakeWebFrame(QRect(0, 10, 100, 100), parent=frame)
        assert frame.geometry().contains(iframe.geometry())
        elems = [
            get_webelem(QRect(0, 0, 10, 10), iframe),
            get_webelem(QRect(20, 90, 10, 10), iframe),
            get_webelem(QRect(20, 150, 10, 10), iframe),
            get_webelem(QRect(30, 180, 10, 10), frame),
        ]

        assert elems[0].is_visible(frame)
        assert elems[1].is_visible(frame)
        assert not elems[2].is_visible(frame)
        assert elems[3].is_visible(frame)

        return self.Objects(frame=frame, iframe=iframe, elems=elems)

    def test_iframe_scrolled(self, objects):
        """Scroll iframe down so elem3 gets visible and elem1/elem2 not."""
        objects.iframe.scrollPosition.return_value = QPoint(0, 100)
        assert not objects.elems[0].is_visible(objects.frame)
        assert not objects.elems[1].is_visible(objects.frame)
        assert objects.elems[2].is_visible(objects.frame)
        assert objects.elems[3].is_visible(objects.frame)

    def test_mainframe_scrolled_iframe_visible(self, objects):
        """Scroll mainframe down so iframe is partly visible but elem1 not."""
        objects.frame.scrollPosition.return_value = QPoint(0, 50)
        geom = objects.frame.geometry().translated(
            objects.frame.scrollPosition())
        assert not geom.contains(objects.iframe.geometry())
        assert geom.intersects(objects.iframe.geometry())
        assert not objects.elems[0].is_visible(objects.frame)
        assert objects.elems[1].is_visible(objects.frame)
        assert not objects.elems[2].is_visible(objects.frame)
        assert objects.elems[3].is_visible(objects.frame)

    def test_mainframe_scrolled_iframe_invisible(self, objects):
        """Scroll mainframe down so iframe is invisible."""
        objects.frame.scrollPosition.return_value = QPoint(0, 110)
        geom = objects.frame.geometry().translated(
            objects.frame.scrollPosition())
        assert not geom.contains(objects.iframe.geometry())
        assert not geom.intersects(objects.iframe.geometry())
        assert not objects.elems[0].is_visible(objects.frame)
        assert not objects.elems[1].is_visible(objects.frame)
        assert not objects.elems[2].is_visible(objects.frame)
        assert objects.elems[3].is_visible(objects.frame)

    @pytest.fixture
    def invalid_objects(self, stubs):
        """Set up the following base situation:

             0, 0                         300, 0
              ##############################
              #                            #
         0,10 # iframe  100,10             #
              #**********                  #
              #* e      * elems[0]: 10, 10 in iframe (visible)
              #*        *                  #
              #*        *                  #
              #**********                  #
        0,110 #.        .100,110           #
              #.        .                  #
              #. e      . elems[2]: 20,150 in iframe (not visible)
              #..........                  #
              ##############################
            300, 0                         300, 300

        Returns an Objects namedtuple with frame/iframe/elems attributes.
        """
        frame = stubs.FakeWebFrame(QRect(0, 0, 300, 300))
        iframe = stubs.FakeWebFrame(QRect(0, 10, 100, 100), parent=frame)
        assert frame.geometry().contains(iframe.geometry())

        elems = [
            get_webelem(QRect(10, 10, 0, 0), iframe),
            get_webelem(QRect(20, 150, 0, 0), iframe),
        ]
        for e in elems:
            assert not e.geometry().isValid()

        return self.Objects(frame=frame, iframe=iframe, elems=elems)

    def test_invalid_visible(self, invalid_objects):
        """Test elements with an invalid geometry which are visible.

        This seems to happen sometimes in the real world, with real elements
        which *are* visible, but don't have a valid geometry.
        """
        elem = invalid_objects.elems[0]
        assert elem.is_visible(invalid_objects.frame)

    def test_invalid_invisible(self, invalid_objects):
        """Test elements with an invalid geometry which are invisible."""
        assert not invalid_objects.elems[1].is_visible(invalid_objects.frame)


def test_focus_element(stubs):
    """Test getting focus element with a fake frame/element.

    Testing this with a real webpage is almost impossible because the window
    and the element would have focus, which is hard to achieve consistently in
    a test.
    """
    frame = stubs.FakeWebFrame(QRect(0, 0, 100, 100))
    elem = get_webelem()
    frame.focus_elem = elem._elem
    assert webelem.focus_elem(frame)._elem is elem._elem


class TestRectOnView:

    def test_simple(self, stubs):
        geometry = QRect(5, 5, 4, 4)
        frame = stubs.FakeWebFrame(QRect(0, 0, 100, 100))
        elem = get_webelem(geometry, frame)
        assert elem.rect_on_view() == QRect(5, 5, 4, 4)

    def test_scrolled(self, stubs):
        geometry = QRect(20, 20, 4, 4)
        frame = stubs.FakeWebFrame(QRect(0, 0, 100, 100),
                                   scroll=QPoint(10, 10))
        elem = get_webelem(geometry, frame)
        assert elem.rect_on_view() == QRect(20 - 10, 20 - 10, 4, 4)

    def test_iframe(self, stubs):
        """Test an element in an iframe.

             0, 0                         200, 0
              ##############################
              #                            #
         0,10 # iframe  100,10             #
              #**********                  #
              #*        *                  #
              #*        *                  #
              #* e      * elem: 20,90 in iframe
              #**********                  #
        0,100 #                            #
              ##############################
            200, 0                         200, 200
        """
        frame = stubs.FakeWebFrame(QRect(0, 0, 200, 200))
        iframe = stubs.FakeWebFrame(QRect(0, 10, 100, 100), parent=frame)
        assert frame.geometry().contains(iframe.geometry())
        elem = get_webelem(QRect(20, 90, 10, 10), iframe)
        assert elem.rect_on_view() == QRect(20, 10 + 90, 10, 10)

    def test_passed_geometry(self, stubs):
        """Make sure geometry isn't called when a geometry is passed."""
        raw_elem = get_webelem()._elem
        rect = QRect(10, 20, 30, 40)
        assert webelem.rect_on_view(raw_elem, rect) == rect
        assert not raw_elem.geometry.called


class TestJavascriptEscape:

    TESTS = {
        'foo\\bar': r'foo\\bar',
        'foo\nbar': r'foo\nbar',
        'foo\rbar': r'foo\rbar',
        "foo'bar": r"foo\'bar",
        'foo"bar': r'foo\"bar',
        'one\\two\rthree\nfour\'five"six': r'one\\two\rthree\nfour\'five\"six',
        '\x00': r'\x00',
        'hellö': 'hellö',
        '☃': '☃',
        '\x80Ā': '\x80Ā',
        '𐀀\x00𐀀\x00': r'𐀀\x00𐀀\x00',
    }

    # Once there was this warning here:
    #   load glyph failed err=6 face=0x2680ba0, glyph=1912
    # http://qutebrowser.org:8010/builders/debian-jessie/builds/765/steps/unittests/
    # Should that be ignored?

    @pytest.mark.parametrize('before, after', TESTS.items(), ids=repr)
    def test_fake_escape(self, before, after):
        """Test javascript escaping with some expected outcomes."""
        assert webelem.javascript_escape(before) == after

    def _test_escape(self, text, qtbot, webframe):
        """Helper function for test_real_escape*."""
        try:
            self._test_escape_simple(text, webframe)
        except AssertionError:
            # Try another method if the simple method failed.
            #
            # See _test_escape_hexlified documentation on why this is
            # necessary.
            self._test_escape_hexlified(text, qtbot, webframe)

    def _test_escape_hexlified(self, text, qtbot, webframe):
        """Test conversion by hexlifying in javascript.

        Since the conversion of QStrings to Python strings is broken in some
        older PyQt versions in some corner cases, we load a HTML file which
        generates an MD5 of the escaped text and use that for comparisons.
        """
        escaped = webelem.javascript_escape(text)
        path = os.path.join(os.path.dirname(__file__),
                            'test_webelem_jsescape.html')
        with open(path, encoding='utf-8') as f:
            html_source = f.read().replace('%INPUT%', escaped)

        with qtbot.waitSignal(webframe.loadFinished, raising=True):
            webframe.setHtml(html_source)

        result = webframe.evaluateJavaScript('window.qute_test_result')
        assert result is not None
        assert '|' in result
        result_md5, result_text = result.split('|', maxsplit=1)
        text_md5 = binascii.hexlify(text.encode('utf-8')).decode('ascii')
        assert result_md5 == text_md5, result_text

    def _test_escape_simple(self, text, webframe):
        """Test conversion by using evaluateJavaScript."""
        escaped = webelem.javascript_escape(text)
        result = webframe.evaluateJavaScript('"{}";'.format(escaped))
        assert result == text

    @pytest.mark.parametrize('text', TESTS, ids=repr)
    def test_real_escape(self, webframe, qtbot, text):
        """Test javascript escaping with a real QWebPage."""
        self._test_escape(text, qtbot, webframe)

    @hypothesis.given(hypothesis.strategies.text())
    @pytest.mark.qt_log_ignore('^load glyph failed')
    def test_real_escape_hypothesis(self, webframe, qtbot, text):
        """Test javascript escaping with a real QWebPage and hypothesis."""
        # We can't simply use self._test_escape because of this:
        # https://github.com/pytest-dev/pytest-qt/issues/69

        # self._test_escape(text, qtbot, webframe)
        try:
            self._test_escape_simple(text, webframe)
        except AssertionError:
            if PYQT_VERSION >= 0x050300:
                self._test_escape_hexlified(text, qtbot, webframe)


class TestGetChildFrames:

    """Check get_child_frames."""

    def test_single_frame(self, stubs):
        """Test get_child_frames with a single frame without children."""
        frame = stubs.FakeChildrenFrame()
        children = webelem.get_child_frames(frame)
        assert len(children) == 1
        assert children[0] is frame
        frame.childFrames.assert_called_once_with()

    def test_one_level(self, stubs):
        r"""Test get_child_frames with one level of children.

                  o   parent
                 / \
        child1  o   o  child2
        """
        child1 = stubs.FakeChildrenFrame()
        child2 = stubs.FakeChildrenFrame()
        parent = stubs.FakeChildrenFrame([child1, child2])
        children = webelem.get_child_frames(parent)
        assert len(children) == 3
        assert children[0] is parent
        assert children[1] is child1
        assert children[2] is child2
        parent.childFrames.assert_called_once_with()
        child1.childFrames.assert_called_once_with()
        child2.childFrames.assert_called_once_with()

    def test_multiple_levels(self, stubs):
        r"""Test get_child_frames with multiple levels of children.

            o      root
           / \
          o   o    first
         /\   /\
        o  o o  o  second
        """
        second = [stubs.FakeChildrenFrame() for _ in range(4)]
        first = [stubs.FakeChildrenFrame(second[0:2]),
                 stubs.FakeChildrenFrame(second[2:4])]
        root = stubs.FakeChildrenFrame(first)
        children = webelem.get_child_frames(root)
        assert len(children) == 7
        assert children[0] is root
        for frame in [root] + first + second:
            frame.childFrames.assert_called_once_with()


class TestIsEditable:

    """Tests for is_editable."""

    @pytest.fixture
    def stubbed_config(self, config_stub, monkeypatch):
        """Fixture to create a config stub with an input section."""
        config_stub.data = {'input': {}}
        monkeypatch.setattr('qutebrowser.browser.webelem.config', config_stub)
        return config_stub

    @pytest.mark.parametrize('tagname, attributes, editable', [
        ('input', {}, True),
        ('input', {'type': 'text'}, True),
        ('INPUT', {'TYPE': 'TEXT'}, True),  # caps attributes/name
        ('input', {'type': 'email'}, True),
        ('input', {'type': 'url'}, True),
        ('input', {'type': 'tel'}, True),
        ('input', {'type': 'number'}, True),
        ('input', {'type': 'password'}, True),
        ('input', {'type': 'search'}, True),
        ('textarea', {}, True),

        ('input', {'type': 'button'}, False),
        ('input', {'type': 'checkbox'}, False),
        ('select', {}, False),

        ('input', {'disabled': None}, False),
        ('input', {'readonly': None}, False),
        ('textarea', {'disabled': None}, False),
        ('textarea', {'readonly': None}, False),

        ('foobar', {}, False),
        ('foobar', {'contenteditable': 'true'}, True),
        ('foobar', {'contenteditable': 'false'}, False),
        ('foobar', {'contenteditable': 'true', 'disabled': None}, False),
        ('foobar', {'contenteditable': 'true', 'readonly': None}, False),

        ('foobar', {'role': 'foobar'}, False),
        ('foobar', {'role': 'combobox'}, True),
        ('foobar', {'role': 'textbox'}, True),
        ('foobar', {'role': 'combobox', 'disabled': None}, False),
        ('foobar', {'role': 'combobox', 'readonly': None}, False),
    ])
    def test_is_editable(self, tagname, attributes, editable):
        elem = get_webelem(tagname=tagname, attributes=attributes)
        assert elem.is_editable() == editable

    @pytest.mark.parametrize('classes, editable', [
        (None, False),
        ('foo-kix-bar', False),
        ('foo kix-foo', True),
        ('KIX-FOO', False),
        ('foo CodeMirror-foo', True),
    ])
    def test_is_editable_div(self, classes, editable):
        elem = get_webelem(tagname='div', classes=classes)
        assert elem.is_editable() == editable

    @pytest.mark.parametrize('setting, tagname, attributes, editable', [
        (True, 'embed', {}, True),
        (True, 'embed', {}, True),
        (False, 'applet', {}, False),
        (False, 'applet', {}, False),
        (True, 'object', {'type': 'application/foo'}, True),
        (False, 'object', {'type': 'application/foo'}, False),
        (True, 'object', {'type': 'foo', 'classid': 'foo'}, True),
        (False, 'object', {'type': 'foo', 'classid': 'foo'}, False),
        (True, 'object', {}, False),
        (True, 'object', {'type': 'image/gif'}, False),
    ])
    def test_is_editable_plugin(self, stubbed_config, setting, tagname,
                                attributes, editable):
        stubbed_config.data['input']['insert-mode-on-plugins'] = setting
        elem = get_webelem(tagname=tagname, attributes=attributes)
        assert elem.is_editable() == editable