diff --git a/qutebrowser/completion/completer.py b/qutebrowser/completion/completer.py index 12f607b51..ebdb1d10b 100644 --- a/qutebrowser/completion/completer.py +++ b/qutebrowser/completion/completer.py @@ -22,7 +22,7 @@ from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QTimer from qutebrowser.config import config -from qutebrowser.commands import cmdexc, cmdutils, runners +from qutebrowser.commands import cmdutils, runners from qutebrowser.utils import usertypes, log, objreg, utils from qutebrowser.completion.models import instances, sortfilter @@ -485,16 +485,3 @@ class Completer(QObject): """Select the next completion item.""" self._open_completion_if_needed() self.next_prev_item.emit(False) - - @cmdutils.register(instance='completion', hide=True, - modes=[usertypes.KeyMode.command], scope='window') - def completion_item_del(self): - """Delete the current completion item.""" - completion = objreg.get('completion', scope='window', - window=self._win_id) - if not completion.currentIndex().isValid(): - raise cmdexc.CommandError("No item selected!") - try: - self.model().srcmodel.delete_cur_item(completion) - except NotImplementedError: - raise cmdexc.CommandError("Cannot delete this item.") diff --git a/qutebrowser/completion/completionwidget.py b/qutebrowser/completion/completionwidget.py index 8c2b8c1fd..80e87dbe4 100644 --- a/qutebrowser/completion/completionwidget.py +++ b/qutebrowser/completion/completionwidget.py @@ -29,7 +29,8 @@ from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QItemSelectionModel from qutebrowser.config import config, style from qutebrowser.completion import completiondelegate, completer from qutebrowser.completion.models import base -from qutebrowser.utils import qtutils, objreg, utils +from qutebrowser.utils import qtutils, objreg, utils, usertypes +from qutebrowser.commands import cmdexc, cmdutils class CompletionView(QTreeView): @@ -277,3 +278,14 @@ class CompletionView(QTreeView): if scrollbar is not None: scrollbar.setValue(scrollbar.minimum()) super().showEvent(e) + + @cmdutils.register(instance='completion', hide=True, + modes=[usertypes.KeyMode.command], scope='window') + def completion_item_del(self): + """Delete the current completion item.""" + if not self.currentIndex().isValid(): + raise cmdexc.CommandError("No item selected!") + try: + self.model().srcmodel.delete_cur_item(self) + except NotImplementedError: + raise cmdexc.CommandError("Cannot delete this item.") diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py index 2530a1ab6..eb8dd9a97 100644 --- a/tests/helpers/fixtures.py +++ b/tests/helpers/fixtures.py @@ -255,6 +255,14 @@ def app_stub(stubs): objreg.delete('app') +@pytest.yield_fixture +def completion_widget_stub(win_registry): + stub = unittest.mock.Mock() + objreg.register('completion', stub, scope='window', window=0) + yield stub + objreg.delete('completion', scope='window', window=0) + + @pytest.fixture(scope='session') def stubs(): """Provide access to stub objects useful for testing.""" diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index fa7f60fa5..d08b35f60 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -27,7 +27,7 @@ from unittest import mock from PyQt5.QtCore import pyqtSignal, QPoint, QProcess, QObject from PyQt5.QtNetwork import (QNetworkRequest, QAbstractNetworkCache, QNetworkCacheMetaData) -from PyQt5.QtWidgets import QCommonStyle, QWidget +from PyQt5.QtWidgets import QCommonStyle, QWidget, QLineEdit from qutebrowser.browser.webkit import webview, history from qutebrowser.config import configexc @@ -293,12 +293,13 @@ class FakeCommand: """A simple command stub which has a description.""" def __init__(self, name='', desc='', hide=False, debug=False, - deprecated=False): + deprecated=False, completion=None): self.desc = desc self.name = name self.hide = hide self.debug = debug self.deprecated = deprecated + self.completion = completion class FakeTimer(QObject): @@ -359,6 +360,24 @@ class FakeConfigType: self.complete = lambda: [(val, '') for val in valid_values] +class FakeStatusbarCommand(QLineEdit): + + """Stub for the statusbar command prompt.""" + + got_cmd = pyqtSignal(str) + clear_completion_selection = pyqtSignal() + hide_completion = pyqtSignal() + update_completion = pyqtSignal() + show_cmd = pyqtSignal() + hide_cmd = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + + def prefix(self): + return self.text()[0] + + class ConfigStub(QObject): """Stub for the config module. diff --git a/tests/unit/completion/test_completer.py b/tests/unit/completion/test_completer.py new file mode 100644 index 000000000..bfb1f0718 --- /dev/null +++ b/tests/unit/completion/test_completer.py @@ -0,0 +1,223 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016 Ryan Roden-Corrent (rcorre) +# +# 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 . + +"""Tests for the Completer Object.""" + +import unittest.mock + +import pytest +from PyQt5.QtGui import QStandardItemModel + +from qutebrowser.completion import completer +from qutebrowser.utils import usertypes + + +class FakeCompletionModel(QStandardItemModel): + + """Stub for a completion model.""" + + DUMB_SORT = None + + def __init__(self, kind, parent=None): + super().__init__(parent) + self.kind = kind + + +@pytest.fixture +def cmd(stubs, qtbot): + """Create the statusbar command prompt the completer uses.""" + cmd = stubs.FakeStatusbarCommand() + qtbot.addWidget(cmd) + return cmd + + +@pytest.fixture +def completer_obj(qtbot, cmd, config_stub): + """Create the completer used for testing.""" + config_stub.data = {'completion': {'auto-open': False}} + return completer.Completer(cmd, 0) + + +@pytest.fixture(autouse=True) +def instances(monkeypatch): + """Mock the instances module so get returns a fake completion model.""" + # populate a model for each completion type, with a nested structure for + # option and value completion + instances = {kind: FakeCompletionModel(kind) + for kind in usertypes.Completion} + instances[usertypes.Completion.option] = { + 'general': FakeCompletionModel(usertypes.Completion.option), + } + instances[usertypes.Completion.value] = { + 'general': { + 'ignore-case': FakeCompletionModel(usertypes.Completion.value), + } + } + monkeypatch.setattr('qutebrowser.completion.completer.instances', + instances) + + +@pytest.fixture(autouse=True) +def cmdutils_patch(monkeypatch, stubs): + """Patch the cmdutils module to provide fake commands.""" + cmds = { + 'set': [usertypes.Completion.section, usertypes.Completion.option, + usertypes.Completion.value], + 'help': [usertypes.Completion.helptopic], + 'quickmark-load': [usertypes.Completion.quickmark_by_name], + 'bookmark-load': [usertypes.Completion.bookmark_by_url], + 'open': [usertypes.Completion.url], + 'buffer': [usertypes.Completion.tab], + 'session-load': [usertypes.Completion.sessions], + 'bind': [usertypes.Completion.empty, usertypes.Completion.command], + 'tab-detach': None, + } + cmd_utils = stubs.FakeCmdUtils({ + name: stubs.FakeCommand(completion=compl) + for name, compl in cmds.items() + }) + monkeypatch.setattr('qutebrowser.completion.completer.cmdutils', + cmd_utils) + + +def _set_cmd_prompt(cmd, txt): + """Set the command prompt's text and cursor position. + + Args: + cmd: The command prompt object. + txt: The prompt text, using | as a placeholder for the cursor position. + """ + cmd.setText(txt.replace('|', '')) + cmd.setCursorPosition(txt.index('|')) + + +def _validate_cmd_prompt(cmd, txt): + """Interpret fake command prompt text using | as the cursor placeholder. + + Args: + cmd: The command prompt object. + txt: The prompt text, using | as a placeholder for the cursor position. + """ + assert cmd.cursorPosition() == txt.index('|') + assert cmd.text() == txt.replace('|', '') + + +@pytest.mark.parametrize('txt, expected', [ + (':nope|', usertypes.Completion.command), + (':nope |', None), + (':set |', usertypes.Completion.section), + (':set gen|', usertypes.Completion.section), + (':set general |', usertypes.Completion.option), + (':set what |', None), + (':set general ignore-case |', usertypes.Completion.value), + (':set general huh |', None), + (':help |', usertypes.Completion.helptopic), + (':quickmark-load |', usertypes.Completion.quickmark_by_name), + (':bookmark-load |', usertypes.Completion.bookmark_by_url), + (':open |', usertypes.Completion.url), + (':buffer |', usertypes.Completion.tab), + (':session-load |', usertypes.Completion.sessions), + (':bind |', usertypes.Completion.empty), + (':bind |', usertypes.Completion.command), + (':bind foo|', usertypes.Completion.command), + (':bind | foo', usertypes.Completion.empty), + (':set| general ', usertypes.Completion.command), + (':|set general ', usertypes.Completion.command), + (':set gene|ral ignore-case', usertypes.Completion.section), + (':|', usertypes.Completion.command), + (': |', usertypes.Completion.command), + (':bookmark-load |', usertypes.Completion.bookmark_by_url), + ('/|', None), + (':open -t|', None), + (':open --tab|', None), + (':open -t |', usertypes.Completion.url), + (':open --tab |', usertypes.Completion.url), + (':open | -t', usertypes.Completion.url), + (':--foo --bar |', None), + (':tab-detach |', None), + (':bind --mode=caret |', usertypes.Completion.command), + pytest.mark.xfail(reason='issue #74')((':bind --mode caret |', + usertypes.Completion.command)), + (':set -t -p |', usertypes.Completion.section), + (':open -- |', None), +]) +def test_update_completion(txt, expected, cmd, completer_obj, + completion_widget_stub): + """Test setting the completion widget's model based on command text.""" + # this test uses | as a placeholder for the current cursor position + _set_cmd_prompt(cmd, txt) + completer_obj.update_completion() + if expected is None: + assert not completion_widget_stub.set_model.called + else: + assert completion_widget_stub.set_model.call_count == 1 + arg = completion_widget_stub.set_model.call_args[0][0] + # the outer model is just for sorting; srcmodel is the completion model + assert arg.srcmodel.kind == expected + + +def test_completion_item_prev(completer_obj, cmd, completion_widget_stub, + config_stub, qtbot): + """Test that completion_item_prev emits next_prev_item.""" + cmd.setText(':') + with qtbot.waitSignal(completer_obj.next_prev_item) as blocker: + completer_obj.completion_item_prev() + assert blocker.args == [True] + + +def test_completion_item_next(completer_obj, cmd, completion_widget_stub, + config_stub, qtbot): + """Test that completion_item_next emits next_prev_item.""" + cmd.setText(':') + with qtbot.waitSignal(completer_obj.next_prev_item) as blocker: + completer_obj.completion_item_next() + assert blocker.args == [False] + + +@pytest.mark.parametrize('before, newtxt, quick_complete, count, after', [ + (':foo |', 'bar', False, 1, ':foo bar|'), + (':foo |', 'bar', True, 2, ':foo bar|'), + (':foo |', 'bar', True, 1, ':foo bar |'), + (':foo | bar', 'baz', False, 1, ':foo baz| bar'), + (':foo |', 'bar baz', True, 1, ":foo 'bar baz' |"), + (':foo |', '', True, 1, ":foo '' |"), + (':foo |', None, True, 1, ":foo |"), +]) +def test_selection_changed(before, newtxt, count, quick_complete, after, + completer_obj, cmd, completion_widget_stub, + config_stub): + """Test that change_completed_part modifies the cmd text properly. + + The | represents the current cursor position in the cmd prompt. + If quick-complete is True and there is only 1 completion (count == 1), + then we expect a space to be appended after the current word. + """ + config_stub.data['completion']['quick-complete'] = quick_complete + model = unittest.mock.Mock() + model.data = unittest.mock.Mock(return_value=newtxt) + model.count = unittest.mock.Mock(return_value=count) + indexes = [unittest.mock.Mock()] + selection = unittest.mock.Mock() + selection.indexes = unittest.mock.Mock(return_value=indexes) + completion_widget_stub.model = unittest.mock.Mock(return_value=model) + _set_cmd_prompt(cmd, before) + completer_obj.update_cursor_part() + completer_obj.selection_changed(selection, None) + model.data.assert_called_with(indexes[0]) + _validate_cmd_prompt(cmd, after)