diff --git a/qutebrowser/config/sections.py b/qutebrowser/config/sections.py index 15b6a096e..254348fe9 100644 --- a/qutebrowser/config/sections.py +++ b/qutebrowser/config/sections.py @@ -54,7 +54,7 @@ class Section: def __iter__(self): """Iterate over all set values.""" - return self.values.__iter__() + return iter(self.values) def __bool__(self): """Get boolean state of section.""" diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py index 060c06927..39e552e3c 100644 --- a/scripts/dev/check_coverage.py +++ b/scripts/dev/check_coverage.py @@ -143,6 +143,9 @@ PERFECT_FILES = [ 'qutebrowser/utils/error.py'), ('tests/unit/utils/test_typing.py', 'qutebrowser/utils/typing.py'), + ('tests/unit/completion/test_models.py', + 'qutebrowser/completion/models/base.py'), + ] diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py index 5de771514..2530a1ab6 100644 --- a/tests/helpers/fixtures.py +++ b/tests/helpers/fixtures.py @@ -198,6 +198,63 @@ def host_blocker_stub(stubs): objreg.delete('host-blocker') +@pytest.yield_fixture +def quickmark_manager_stub(stubs): + """Fixture which provides a fake quickmark manager object.""" + stub = stubs.QuickmarkManagerStub() + objreg.register('quickmark-manager', stub) + yield stub + objreg.delete('quickmark-manager') + + +@pytest.yield_fixture +def bookmark_manager_stub(stubs): + """Fixture which provides a fake bookmark manager object.""" + stub = stubs.BookmarkManagerStub() + objreg.register('bookmark-manager', stub) + yield stub + objreg.delete('bookmark-manager') + + +@pytest.yield_fixture +def web_history_stub(stubs): + """Fixture which provides a fake web-history object.""" + stub = stubs.WebHistoryStub() + objreg.register('web-history', stub) + yield stub + objreg.delete('web-history') + + +@pytest.yield_fixture +def session_manager_stub(stubs): + """Fixture which provides a fake web-history object.""" + stub = stubs.SessionManagerStub() + objreg.register('session-manager', stub) + yield stub + objreg.delete('session-manager') + + +@pytest.yield_fixture +def tabbed_browser_stubs(stubs, win_registry): + """Fixture providing a fake tabbed-browser object on win_id 0 and 1.""" + win_registry.add_window(1) + stubs = [stubs.TabbedBrowserStub(), stubs.TabbedBrowserStub()] + objreg.register('tabbed-browser', stubs[0], scope='window', window=0) + objreg.register('tabbed-browser', stubs[1], scope='window', window=1) + yield stubs + objreg.delete('tabbed-browser', scope='window', window=0) + objreg.delete('tabbed-browser', scope='window', window=1) + + +@pytest.yield_fixture +def app_stub(stubs): + """Fixture which provides a fake app object.""" + stub = stubs.ApplicationStub() + objreg.register('app', stub) + yield stub + objreg.delete('app') + + @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 87c5594c5..fa7f60fa5 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -21,6 +21,7 @@ """Fake objects/stubs.""" +import collections from unittest import mock from PyQt5.QtCore import pyqtSignal, QPoint, QProcess, QObject @@ -28,8 +29,9 @@ from PyQt5.QtNetwork import (QNetworkRequest, QAbstractNetworkCache, QNetworkCacheMetaData) from PyQt5.QtWidgets import QCommonStyle, QWidget -from qutebrowser.browser.webkit import webview +from qutebrowser.browser.webkit import webview, history from qutebrowser.config import configexc +from qutebrowser.mainwindow import mainwindow class FakeNetworkCache(QAbstractNetworkCache): @@ -218,13 +220,20 @@ class FakeWebView(QWidget): """Fake WebView which can be added to a tab.""" - def __init__(self): + url_text_changed = pyqtSignal(str) + shutting_down = pyqtSignal() + + def __init__(self, url=FakeUrl(), title='', tab_id=0): super().__init__() self.progress = 0 self.scroll_pos = (-1, -1) self.load_status = webview.LoadStatus.none - self.tab_id = 0 - self.cur_url = FakeUrl() + self.tab_id = tab_id + self.cur_url = url + self.title = title + + def url(self): + return self.cur_url class FakeSignal: @@ -283,8 +292,13 @@ class FakeCommand: """A simple command stub which has a description.""" - def __init__(self, desc): + def __init__(self, name='', desc='', hide=False, debug=False, + deprecated=False): self.desc = desc + self.name = name + self.hide = hide + self.debug = debug + self.deprecated = deprecated class FakeTimer(QObject): @@ -335,6 +349,16 @@ class FakeTimer(QObject): return self._started +class FakeConfigType: + + """A stub to provide valid_values for typ attribute of a SettingValue.""" + + def __init__(self, *valid_values): + # normally valid_values would be a ValidValues, but for simplicity of + # testing this can be a simple list: [(val, desc), (val, desc), ...] + self.complete = lambda: [(val, '') for val in valid_values] + + class ConfigStub(QObject): """Stub for the config module. @@ -368,7 +392,7 @@ class ConfigStub(QObject): """ return self.data[name] - def get(self, sect, opt): + def get(self, sect, opt, raw=True): """Get a value from the config.""" data = self.data[sect] try: @@ -400,9 +424,103 @@ class KeyConfigStub: self.bindings[section] = bindings +class UrlMarkManagerStub(QObject): + + """Stub for the quickmark-manager or bookmark-manager object.""" + + added = pyqtSignal(str, str) + removed = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.marks = {} + + def delete(self, key): + del self.marks[key] + self.removed.emit(key) + + +class BookmarkManagerStub(UrlMarkManagerStub): + + """Stub for the bookmark-manager object.""" + + pass + + +class QuickmarkManagerStub(UrlMarkManagerStub): + + """Stub for the quickmark-manager object.""" + + def quickmark_del(self, key): + self.delete(key) + + +class WebHistoryStub(QObject): + + """Stub for the web-history object.""" + + add_completion_item = pyqtSignal(history.Entry) + cleared = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self.history_dict = collections.OrderedDict() + + def __iter__(self): + return iter(self.history_dict.values()) + + def __len__(self): + return len(self.history_dict) + + class HostBlockerStub: """Stub for the host-blocker object.""" def __init__(self): self.blocked_hosts = set() + + +class SessionManagerStub: + + """Stub for the session-manager object.""" + + def __init__(self): + self.sessions = [] + + def list_sessions(self): + return self.sessions + + +class TabbedBrowserStub(QObject): + + """Stub for the tabbed-browser object.""" + + new_tab = pyqtSignal(webview.WebView, int) + + def __init__(self, parent=None): + super().__init__(parent) + self.tabs = [] + self.shutting_down = False + + def count(self): + return len(self.tabs) + + def widget(self, i): + return self.tabs[i] + + def page_title(self, i): + return self.tabs[i].title + + def on_tab_close_requested(self, idx): + del self.tabs[idx] + + +class ApplicationStub(QObject): + + """Stub to insert as the app object in objreg.""" + + new_window = pyqtSignal(mainwindow.MainWindow) + + def __init__(self): + super().__init__() diff --git a/tests/unit/browser/webkit/test_history.py b/tests/unit/browser/webkit/test_history.py index dbc2c6950..2aabd7b54 100644 --- a/tests/unit/browser/webkit/test_history.py +++ b/tests/unit/browser/webkit/test_history.py @@ -391,3 +391,4 @@ def test_init(qapp, tmpdir, monkeypatch, fake_save_manager): assert hist.parent() is qapp assert QWebHistoryInterface.defaultInterface()._history is hist assert fake_save_manager.add_saveable.called + objreg.delete('web-history') diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py new file mode 100644 index 000000000..064ed9533 --- /dev/null +++ b/tests/unit/completion/test_models.py @@ -0,0 +1,395 @@ +# 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 completion models.""" + +import collections +from datetime import datetime + +import pytest +from PyQt5.QtCore import QUrl +from PyQt5.QtWidgets import QTreeView + +from qutebrowser.completion.models import miscmodels, urlmodel, configmodel +from qutebrowser.browser.webkit import history +from qutebrowser.config import sections, value + + +def _get_completions(model): + """Collect all the completion entries of a model, organized by category. + + The result is a list of form: + [ + (CategoryName: [(name, desc, misc), ...]), + (CategoryName: [(name, desc, misc), ...]), + ... + ] + """ + completions = [] + for i in range(0, model.rowCount()): + category = model.item(i) + entries = [] + for j in range(0, category.rowCount()): + name = category.child(j, 0) + desc = category.child(j, 1) + misc = category.child(j, 2) + entries.append((name.text(), desc.text(), misc.text())) + completions.append((category.text(), entries)) + return completions + + +def _patch_cmdutils(monkeypatch, stubs, symbol): + """Patch the cmdutils module to provide fake commands.""" + cmd_utils = stubs.FakeCmdUtils({ + 'stop': stubs.FakeCommand(name='stop', desc='stop qutebrowser'), + 'drop': stubs.FakeCommand(name='drop', desc='drop all user data'), + 'roll': stubs.FakeCommand(name='roll', desc='never gonna give you up'), + 'hide': stubs.FakeCommand(name='hide', hide=True), + 'depr': stubs.FakeCommand(name='depr', deprecated=True), + }) + monkeypatch.setattr(symbol, cmd_utils) + + +def _patch_configdata(monkeypatch, stubs, symbol): + """Patch the configdata module to provide fake data.""" + data = collections.OrderedDict([ + ('general', sections.KeyValue( + ('time', + value.SettingValue(stubs.FakeConfigType('fast', 'slow'), + default='slow'), + 'Is an illusion.\n\nLunchtime doubly so.'), + ('volume', + value.SettingValue(stubs.FakeConfigType('0', '11'), + default='11'), + 'Goes to 11'))), + ('ui', sections.KeyValue( + ('gesture', + value.SettingValue(stubs.FakeConfigType(('on', 'off')), + default='off'), + 'Waggle your hands to control qutebrowser'), + ('mind', + value.SettingValue(stubs.FakeConfigType(('on', 'off')), + default='off'), + 'Enable mind-control ui (experimental)'), + ('voice', + value.SettingValue(stubs.FakeConfigType(('on', 'off')), + default='off'), + 'Whether to respond to voice commands'))), + ]) + monkeypatch.setattr(symbol, data) + + +def _patch_config_section_desc(monkeypatch, stubs, symbol): + """Patch the configdata module to provide fake SECTION_DESC.""" + section_desc = { + 'general': 'General/miscellaneous options.', + 'ui': 'General options related to the user interface.', + } + monkeypatch.setattr(symbol, section_desc) + + +def _mock_view_index(model, category_idx, child_idx, qtbot): + """Create a tree view from a model and set the current index. + + Args: + model: model to create a fake view for. + category_idx: index of the category to select. + child_idx: index of the child item under that category to select. + """ + view = QTreeView() + qtbot.add_widget(view) + view.setModel(model) + idx = model.indexFromItem(model.item(category_idx).child(child_idx)) + view.setCurrentIndex(idx) + return view + + +@pytest.fixture +def quickmarks(quickmark_manager_stub): + """Pre-populate the quickmark-manager stub with some quickmarks.""" + quickmark_manager_stub.marks = collections.OrderedDict([ + ('aw', 'https://wiki.archlinux.org'), + ('ddg', 'https://duckduckgo.com'), + ('wiki', 'https://wikipedia.org'), + ]) + return quickmark_manager_stub + + +@pytest.fixture +def bookmarks(bookmark_manager_stub): + """Pre-populate the bookmark-manager stub with some quickmarks.""" + bookmark_manager_stub.marks = collections.OrderedDict([ + ('https://github.com', 'GitHub'), + ('https://python.org', 'Welcome to Python.org'), + ('http://qutebrowser.org', 'qutebrowser | qutebrowser'), + ]) + return bookmark_manager_stub + + +@pytest.fixture +def web_history(stubs, web_history_stub): + """Pre-populate the web-history stub with some history entries.""" + web_history_stub.history_dict = collections.OrderedDict([ + ('http://qutebrowser.org', history.Entry( + datetime(2015, 9, 5).timestamp(), + QUrl('http://qutebrowser.org'), 'qutebrowser | qutebrowser')), + ('https://python.org', history.Entry( + datetime(2016, 3, 8).timestamp(), + QUrl('https://python.org'), 'Welcome to Python.org')), + ('https://github.com', history.Entry( + datetime(2016, 5, 1).timestamp(), + QUrl('https://github.com'), 'GitHub')), + ]) + return web_history_stub + + +def test_command_completion(monkeypatch, stubs, config_stub, key_config_stub): + """Test the results of command completion. + + Validates that: + - only non-hidden and non-deprecated commands are included + - commands are sorted by name + - the command description is shown in the desc column + - the binding (if any) is shown in the misc column + - aliases are included + """ + _patch_cmdutils(monkeypatch, stubs, + 'qutebrowser.completion.models.miscmodels.cmdutils') + config_stub.data['aliases'] = {'rock': 'roll'} + key_config_stub.set_bindings_for('normal', {'s': 'stop', 'rr': 'roll'}) + actual = _get_completions(miscmodels.CommandCompletionModel()) + assert actual == [ + ("Commands", [ + ('drop', 'drop all user data', ''), + ('rock', "Alias for 'roll'", ''), + ('roll', 'never gonna give you up', 'rr'), + ('stop', 'stop qutebrowser', 's') + ]) + ] + + +def test_help_completion(monkeypatch, stubs): + """Test the results of command completion. + + Validates that: + - only non-hidden and non-deprecated commands are included + - commands are sorted by name + - the command description is shown in the desc column + - the binding (if any) is shown in the misc column + - aliases are included + - only the first line of a multiline description is shown + """ + module = 'qutebrowser.completion.models.miscmodels' + _patch_cmdutils(monkeypatch, stubs, module + '.cmdutils') + _patch_configdata(monkeypatch, stubs, module + '.configdata.DATA') + actual = _get_completions(miscmodels.HelpCompletionModel()) + assert actual == [ + ("Commands", [ + (':drop', 'drop all user data', ''), + (':roll', 'never gonna give you up', ''), + (':stop', 'stop qutebrowser', '') + ]), + ("Settings", [ + ('general->time', 'Is an illusion.', ''), + ('general->volume', 'Goes to 11', ''), + ('ui->gesture', 'Waggle your hands to control qutebrowser', ''), + ('ui->mind', 'Enable mind-control ui (experimental)', ''), + ('ui->voice', 'Whether to respond to voice commands', ''), + ]) + ] + + +def test_quickmark_completion(quickmarks): + """Test the results of quickmark completion.""" + actual = _get_completions(miscmodels.QuickmarkCompletionModel()) + assert actual == [ + ("Quickmarks", [ + ('aw', 'https://wiki.archlinux.org', ''), + ('ddg', 'https://duckduckgo.com', ''), + ('wiki', 'https://wikipedia.org', ''), + ]) + ] + + +def test_bookmark_completion(bookmarks): + """Test the results of bookmark completion.""" + actual = _get_completions(miscmodels.BookmarkCompletionModel()) + assert actual == [ + ("Bookmarks", [ + ('https://github.com', 'GitHub', ''), + ('https://python.org', 'Welcome to Python.org', ''), + ('http://qutebrowser.org', 'qutebrowser | qutebrowser', ''), + ]) + ] + + +def test_url_completion(config_stub, web_history, quickmarks, bookmarks): + """Test the results of url completion. + + Verify that: + - quickmarks, bookmarks, and urls are included + - no more than 'web-history-max-items' history entries are included + - the most recent entries are included + """ + config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d', + 'web-history-max-items': 2} + actual = _get_completions(urlmodel.UrlCompletionModel()) + assert actual == [ + ("Quickmarks", [ + ('https://wiki.archlinux.org', 'aw', ''), + ('https://duckduckgo.com', 'ddg', ''), + ('https://wikipedia.org', 'wiki', ''), + ]), + ("Bookmarks", [ + ('https://github.com', 'GitHub', ''), + ('https://python.org', 'Welcome to Python.org', ''), + ('http://qutebrowser.org', 'qutebrowser | qutebrowser', ''), + ]), + ("History", [ + ('https://python.org', 'Welcome to Python.org', '2016-03-08'), + ('https://github.com', 'GitHub', '2016-05-01'), + ]), + ] + + +def test_url_completion_delete_bookmark(config_stub, web_history, quickmarks, + bookmarks, qtbot): + """Test deleting a bookmark from the url completion model.""" + config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d', + 'web-history-max-items': 2} + model = urlmodel.UrlCompletionModel() + # delete item (1, 0) -> (bookmarks, 'https://github.com' ) + view = _mock_view_index(model, 1, 0, qtbot) + model.delete_cur_item(view) + assert 'https://github.com' not in bookmarks.marks + assert 'https://python.org' in bookmarks.marks + assert 'http://qutebrowser.org' in bookmarks.marks + + +def test_url_completion_delete_quickmark(config_stub, web_history, quickmarks, + bookmarks, qtbot): + """Test deleting a bookmark from the url completion model.""" + config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d', + 'web-history-max-items': 2} + model = urlmodel.UrlCompletionModel() + # delete item (0, 1) -> (quickmarks, 'ddg' ) + view = _mock_view_index(model, 0, 1, qtbot) + model.delete_cur_item(view) + assert 'aw' in quickmarks.marks + assert 'ddg' not in quickmarks.marks + assert 'wiki' in quickmarks.marks + + +def test_session_completion(session_manager_stub): + session_manager_stub.sessions = ['default', '1', '2'] + actual = _get_completions(miscmodels.SessionCompletionModel()) + assert actual == [ + ("Sessions", [('default', '', ''), ('1', '', ''), ('2', '', '')]) + ] + + +def test_tab_completion(stubs, qtbot, app_stub, win_registry, + tabbed_browser_stubs): + tabbed_browser_stubs[0].tabs = [ + stubs.FakeWebView(QUrl('https://github.com'), 'GitHub', 0), + stubs.FakeWebView(QUrl('https://wikipedia.org'), 'Wikipedia', 1), + stubs.FakeWebView(QUrl('https://duckduckgo.com'), 'DuckDuckGo', 2) + ] + tabbed_browser_stubs[1].tabs = [ + stubs.FakeWebView(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0), + ] + actual = _get_completions(miscmodels.TabCompletionModel()) + assert actual == [ + ('0', [ + ('0/1', 'https://github.com', 'GitHub'), + ('0/2', 'https://wikipedia.org', 'Wikipedia'), + ('0/3', 'https://duckduckgo.com', 'DuckDuckGo') + ]), + ('1', [ + ('1/1', 'https://wiki.archlinux.org', 'ArchWiki'), + ]) + ] + + +def test_tab_completion_delete(stubs, qtbot, app_stub, win_registry, + tabbed_browser_stubs): + """Verify closing a tab by deleting it from the completion widget.""" + tabbed_browser_stubs[0].tabs = [ + stubs.FakeWebView(QUrl('https://github.com'), 'GitHub', 0), + stubs.FakeWebView(QUrl('https://wikipedia.org'), 'Wikipedia', 1), + stubs.FakeWebView(QUrl('https://duckduckgo.com'), 'DuckDuckGo', 2) + ] + tabbed_browser_stubs[1].tabs = [ + stubs.FakeWebView(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0), + ] + model = miscmodels.TabCompletionModel() + view = _mock_view_index(model, 0, 1, qtbot) + qtbot.add_widget(view) + model.delete_cur_item(view) + actual = [tab.url() for tab in tabbed_browser_stubs[0].tabs] + assert actual == [QUrl('https://github.com'), + QUrl('https://duckduckgo.com')] + + +def test_setting_section_completion(monkeypatch, stubs): + module = 'qutebrowser.completion.models.configmodel' + _patch_configdata(monkeypatch, stubs, module + '.configdata.DATA') + _patch_config_section_desc(monkeypatch, stubs, + module + '.configdata.SECTION_DESC') + actual = _get_completions(configmodel.SettingSectionCompletionModel()) + assert actual == [ + ("Sections", [ + ('general', 'General/miscellaneous options.', ''), + ('ui', 'General options related to the user interface.', ''), + ]) + ] + + +def test_setting_option_completion(monkeypatch, stubs, config_stub): + module = 'qutebrowser.completion.models.configmodel' + _patch_configdata(monkeypatch, stubs, module + '.configdata.DATA') + config_stub.data = {'ui': {'gesture': 'off', + 'mind': 'on', + 'voice': 'sometimes'}} + actual = _get_completions(configmodel.SettingOptionCompletionModel('ui')) + assert actual == [ + ("ui", [ + ('gesture', 'Waggle your hands to control qutebrowser', 'off'), + ('mind', 'Enable mind-control ui (experimental)', 'on'), + ('voice', 'Whether to respond to voice commands', 'sometimes'), + ]) + ] + + +def test_setting_value_completion(monkeypatch, stubs, config_stub): + module = 'qutebrowser.completion.models.configmodel' + _patch_configdata(monkeypatch, stubs, module + '.configdata.DATA') + config_stub.data = {'general': {'volume': '0'}} + model = configmodel.SettingValueCompletionModel('general', 'volume') + actual = _get_completions(model) + assert actual == [ + ("Current/Default", [ + ('0', 'Current value', ''), + ('11', 'Default value', ''), + ]), + ("Completions", [ + ('0', '', ''), + ('11', '', ''), + ]) + ] diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py index 729256f68..6998d86cd 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -823,8 +823,9 @@ class TestCommand: @pytest.fixture(autouse=True) def patch(self, monkeypatch, stubs): """Patch the cmdutils module to provide fake commands.""" - cmd_utils = stubs.FakeCmdUtils({'cmd1': stubs.FakeCommand("desc 1"), - 'cmd2': stubs.FakeCommand("desc 2")}) + cmd_utils = stubs.FakeCmdUtils({ + 'cmd1': stubs.FakeCommand(desc="desc 1"), + 'cmd2': stubs.FakeCommand(desc="desc 2")}) monkeypatch.setattr('qutebrowser.config.configtypes.cmdutils', cmd_utils)