Merge branch 'rcorre-completer_tests'

This commit is contained in:
Florian Bruhin 2016-08-01 16:03:03 +02:00
commit 9488cc61ef
7 changed files with 289 additions and 124 deletions

View File

@ -19,7 +19,7 @@
"""Completer attached to a CompletionView."""
from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QTimer
from PyQt5.QtCore import pyqtSlot, QObject, QTimer, QItemSelection
from qutebrowser.config import config
from qutebrowser.commands import cmdutils, runners
@ -42,15 +42,8 @@ class Completer(QObject):
_last_text: The old command text so we avoid double completion updates.
_signals_connected: Whether the signals are connected to update the
completion when the command widget requests that.
Signals:
next_prev_item: Emitted to select the next/previous item in the
completion.
arg0: True for the previous item, False for the next.
"""
next_prev_item = pyqtSignal(bool)
def __init__(self, cmd, win_id, parent=None):
super().__init__(parent)
self._win_id = win_id
@ -61,25 +54,25 @@ class Completer(QObject):
self._timer = QTimer()
self._timer.setSingleShot(True)
self._timer.setInterval(0)
self._timer.timeout.connect(self.update_completion)
self._timer.timeout.connect(self._update_completion)
self._cursor_part = None
self._last_cursor_pos = None
self._last_text = None
objreg.get('config').changed.connect(self.on_auto_open_changed)
self.handle_signal_connections()
objreg.get('config').changed.connect(self._on_auto_open_changed)
self._handle_signal_connections()
self._cmd.clear_completion_selection.connect(
self.handle_signal_connections)
self._handle_signal_connections)
def __repr__(self):
return utils.get_repr(self)
@config.change_filter('completion', 'auto-open')
def on_auto_open_changed(self):
self.handle_signal_connections()
def _on_auto_open_changed(self):
self._handle_signal_connections()
@pyqtSlot()
def handle_signal_connections(self):
def _handle_signal_connections(self):
self._connect_signals(config.get('completion', 'auto-open'))
def _connect_signals(self, connect=True):
@ -95,7 +88,7 @@ class Completer(QObject):
"""
connections = [
(self._cmd.update_completion, self.schedule_completion_update),
(self._cmd.textChanged, self.on_text_edited),
(self._cmd.textChanged, self._on_text_edited),
]
if connect and not self._signals_connected:
@ -121,12 +114,11 @@ class Completer(QObject):
if not config.get('completion', 'auto-open'):
connected = self._connect_signals(True)
if connected:
self.update_completion()
self._update_completion()
def _model(self):
"""Convenience method to get the current completion model."""
completion = objreg.get('completion', scope='window',
window=self._win_id)
completion = self.parent()
return completion.model()
def _get_completion_model(self, completion, parts, cursor_part):
@ -249,7 +241,8 @@ class Completer(QObject):
else:
return s
def selection_changed(self, selected, _deselected):
@pyqtSlot(QItemSelection)
def on_selection_changed(self, selected):
"""Change the completed part if a new item was selected.
Called from the views selectionChanged method.
@ -258,6 +251,7 @@ class Completer(QObject):
selected: New selection.
_deselected: Previous selection.
"""
self._open_completion_if_needed()
indexes = selected.indexes()
if not indexes:
return
@ -265,7 +259,7 @@ class Completer(QObject):
data = model.data(indexes[0])
if data is None:
return
parts = self.split()
parts = self._split()
try:
needs_quoting = cmdutils.cmd_dict[parts[0]].maxsplit is None
except KeyError:
@ -275,11 +269,11 @@ class Completer(QObject):
if model.count() == 1 and config.get('completion', 'quick-complete'):
# If we only have one item, we want to apply it immediately
# and go on to the next part.
self.change_completed_part(data, immediate=True)
self._change_completed_part(data, immediate=True)
else:
log.completion.debug("Will ignore next completion update.")
self._ignore_change = True
self.change_completed_part(data)
self._change_completed_part(data)
@pyqtSlot()
def schedule_completion_update(self):
@ -299,10 +293,10 @@ class Completer(QObject):
self._last_text = self._cmd.text()
@pyqtSlot()
def update_completion(self):
def _update_completion(self):
"""Check if completions are available and activate them."""
self.update_cursor_part()
parts = self.split()
self._update_cursor_part()
parts = self._split()
log.completion.debug(
"Updating completion - prefix {}, parts {}, cursor_part {}".format(
@ -314,8 +308,7 @@ class Completer(QObject):
self._ignore_change = False
return
completion = objreg.get('completion', scope='window',
window=self._win_id)
completion = self.parent()
if self._cmd.prefix() != ':':
# This is a search or gibberish, so we don't need to complete
@ -354,7 +347,7 @@ class Completer(QObject):
if completion.enabled:
completion.show()
def split(self, keep=False):
def _split(self, keep=False):
"""Get the text split up in parts.
Args:
@ -381,13 +374,13 @@ class Completer(QObject):
return parts
@pyqtSlot()
def update_cursor_part(self):
def _update_cursor_part(self):
"""Get the part index of the commandline where the cursor is over."""
cursor_pos = self._cmd.cursorPosition()
snippet = slice(cursor_pos - 1, cursor_pos + 1)
spaces = self._cmd.text()[snippet] == ' '
cursor_pos -= len(self._cmd.prefix())
parts = self.split(keep=True)
parts = self._split(keep=True)
log.completion.vdebug(
"text: {}, parts: {}, cursor_pos after removing prefix '{}': "
"{}".format(self._cmd.text(), parts, self._cmd.prefix(),
@ -429,7 +422,7 @@ class Completer(QObject):
self._cursor_part, spaces))
return
def change_completed_part(self, newtext, immediate=False):
def _change_completed_part(self, newtext, immediate=False):
"""Change the part we're currently completing in the commandline.
Args:
@ -438,7 +431,7 @@ class Completer(QObject):
including a trailing space and we shouldn't continue
completing the current item.
"""
parts = self.split()
parts = self._split()
log.completion.debug("changing part {} to '{}'".format(
self._cursor_part, newtext))
try:
@ -465,23 +458,9 @@ class Completer(QObject):
self._cmd.show_cmd.emit()
@pyqtSlot()
def on_text_edited(self):
def _on_text_edited(self):
"""Reset _empty_item_idx if text was edited."""
self._empty_item_idx = None
# We also want to update the cursor part and emit update_completion
# We also want to update the cursor part and emit _update_completion
# here, but that's already done for us by cursorPositionChanged
# anyways, so we don't need to do it twice.
@cmdutils.register(instance='completer', hide=True,
modes=[usertypes.KeyMode.command], scope='window')
def completion_item_prev(self):
"""Select the previous completion item."""
self._open_completion_if_needed()
self.next_prev_item.emit(True)
@cmdutils.register(instance='completer', hide=True,
modes=[usertypes.KeyMode.command], scope='window')
def completion_item_next(self):
"""Select the next completion item."""
self._open_completion_if_needed()
self.next_prev_item.emit(False)

View File

@ -24,10 +24,11 @@ subclasses to provide completions.
"""
from PyQt5.QtWidgets import QStyle, QTreeView, QSizePolicy
from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QItemSelectionModel
from PyQt5.QtCore import (pyqtSlot, pyqtSignal, Qt, QItemSelectionModel,
QItemSelection)
from qutebrowser.config import config, style
from qutebrowser.completion import completiondelegate, completer
from qutebrowser.completion import completiondelegate
from qutebrowser.completion.models import base
from qutebrowser.utils import qtutils, objreg, utils, usertypes
from qutebrowser.commands import cmdexc, cmdutils
@ -50,6 +51,7 @@ class CompletionView(QTreeView):
Signals:
resize_completion: Emitted when the completion should be resized.
selection_changed: Emitted when the completion item selection changes.
"""
# Drawing the item foreground will be done by CompletionItemDelegate, so we
@ -102,16 +104,11 @@ class CompletionView(QTreeView):
"""
resize_completion = pyqtSignal()
selection_changed = pyqtSignal(QItemSelection)
def __init__(self, win_id, parent=None):
super().__init__(parent)
self._win_id = win_id
objreg.register('completion', self, scope='window', window=win_id)
cmd = objreg.get('status-command', scope='window', window=win_id)
completer_obj = completer.Completer(cmd, win_id, self)
completer_obj.next_prev_item.connect(self.on_next_prev_item)
objreg.register('completer', completer_obj, scope='window',
window=win_id)
self.enabled = config.get('completion', 'show')
objreg.get('config').changed.connect(self.set_enabled)
# FIXME handle new aliases.
@ -184,21 +181,17 @@ class CompletionView(QTreeView):
# Item is a real item, not a category header -> success
return idx
@pyqtSlot(bool)
def on_next_prev_item(self, prev):
def _next_prev_item(self, prev):
"""Handle a tab press for the CompletionView.
Select the previous/next item and write the new text to the
statusbar.
Called from the Completer's next_prev_item signal.
Helper for completion_item_next and completion_item_prev.
Args:
prev: True for prev item, False for next one.
"""
if not self.isVisible():
# No completion running at the moment, ignore keypress
return
idx = self._next_idx(prev)
qtutils.ensure_valid(idx)
self.selectionModel().setCurrentIndex(
@ -262,9 +255,7 @@ class CompletionView(QTreeView):
def selectionChanged(self, selected, deselected):
"""Extend selectionChanged to call completers selection_changed."""
super().selectionChanged(selected, deselected)
completer_obj = objreg.get('completer', scope='window',
window=self._win_id)
completer_obj.selection_changed(selected, deselected)
self.selection_changed.emit(selected)
def resizeEvent(self, e):
"""Extend resizeEvent to adjust column size."""
@ -279,6 +270,18 @@ class CompletionView(QTreeView):
scrollbar.setValue(scrollbar.minimum())
super().showEvent(e)
@cmdutils.register(instance='completion', hide=True,
modes=[usertypes.KeyMode.command], scope='window')
def completion_item_prev(self):
"""Select the previous completion item."""
self._next_prev_item(True)
@cmdutils.register(instance='completion', hide=True,
modes=[usertypes.KeyMode.command], scope='window')
def completion_item_next(self):
"""Select the next completion item."""
self._next_prev_item(False)
@cmdutils.register(instance='completion', hide=True,
modes=[usertypes.KeyMode.command], scope='window')
def completion_item_del(self):

View File

@ -32,7 +32,7 @@ from qutebrowser.config import config
from qutebrowser.utils import message, log, usertypes, qtutils, objreg, utils
from qutebrowser.mainwindow import tabbedbrowser
from qutebrowser.mainwindow.statusbar import bar
from qutebrowser.completion import completionwidget
from qutebrowser.completion import completionwidget, completer
from qutebrowser.keyinput import modeman
from qutebrowser.browser import commands, downloadview, hints
from qutebrowser.browser.webkit import downloads
@ -131,23 +131,13 @@ class MainWindow(QWidget):
self._vbox.setContentsMargins(0, 0, 0, 0)
self._vbox.setSpacing(0)
log.init.debug("Initializing downloads...")
download_manager = downloads.DownloadManager(self.win_id, self)
objreg.register('download-manager', download_manager, scope='window',
window=self.win_id)
self._init_downloadmanager()
self._downloadview = downloadview.DownloadView(self.win_id)
self.tabbed_browser = tabbedbrowser.TabbedBrowser(self.win_id)
objreg.register('tabbed-browser', self.tabbed_browser, scope='window',
window=self.win_id)
dispatcher = commands.CommandDispatcher(self.win_id,
self.tabbed_browser)
objreg.register('command-dispatcher', dispatcher, scope='window',
window=self.win_id)
self.tabbed_browser.destroyed.connect(
functools.partial(objreg.delete, 'command-dispatcher',
scope='window', window=self.win_id))
self._init_command_dispatcher()
# We need to set an explicit parent for StatusBar because it does some
# show/hide magic immediately which would mean it'd show up as a
@ -157,7 +147,7 @@ class MainWindow(QWidget):
self._add_widgets()
self._downloadview.show()
self._completion = completionwidget.CompletionView(self.win_id, self)
self._init_completion()
self._commandrunner = runners.CommandRunner(self.win_id,
partial_match=True)
@ -190,6 +180,30 @@ class MainWindow(QWidget):
objreg.get("app").new_window.emit(self)
def _init_downloadmanager(self):
log.init.debug("Initializing downloads...")
download_manager = downloads.DownloadManager(self.win_id, self)
objreg.register('download-manager', download_manager, scope='window',
window=self.win_id)
def _init_completion(self):
self._completion = completionwidget.CompletionView(self.win_id, self)
cmd = objreg.get('status-command', scope='window', window=self.win_id)
completer_obj = completer.Completer(cmd, self.win_id, self._completion)
self._completion.selection_changed.connect(
completer_obj.on_selection_changed)
objreg.register('completion', self._completion, scope='window',
window=self.win_id)
def _init_command_dispatcher(self):
dispatcher = commands.CommandDispatcher(self.win_id,
self.tabbed_browser)
objreg.register('command-dispatcher', dispatcher, scope='window',
window=self.win_id)
self.tabbed_browser.destroyed.connect(
functools.partial(objreg.delete, 'command-dispatcher',
scope='window', window=self.win_id))
def __repr__(self):
return utils.get_repr(self)

View File

@ -268,11 +268,13 @@ def app_stub(stubs):
@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)
def status_command_stub(stubs, qtbot, win_registry):
"""Fixture which provides a fake status-command object."""
cmd = stubs.StatusBarCommandStub()
objreg.register('status-command', cmd, scope='window', window=0)
qtbot.addWidget(cmd)
yield cmd
objreg.delete('status-command', scope='window', window=0)
@pytest.fixture(scope='session')

View File

@ -381,6 +381,28 @@ class FakeTimer(QObject):
return self._started
class InstaTimer(QObject):
"""Stub for a QTimer that fires instantly on start().
Useful to test a time-based event without inserting an artificial delay.
"""
timeout = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
def start(self):
self.timeout.emit()
def setSingleShot(self, yes):
pass
def setInterval(self, interval):
pass
class FakeConfigType:
"""A stub to provide valid_values for typ attribute of a SettingValue."""
@ -391,7 +413,7 @@ class FakeConfigType:
self.complete = lambda: [(val, '') for val in valid_values]
class FakeStatusbarCommand(QLineEdit):
class StatusBarCommandStub(QLineEdit):
"""Stub for the statusbar command prompt."""

View File

@ -22,6 +22,7 @@
import unittest.mock
import pytest
from PyQt5.QtCore import QObject
from PyQt5.QtGui import QStandardItemModel
from qutebrowser.completion import completer
@ -39,19 +40,33 @@ class FakeCompletionModel(QStandardItemModel):
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
class CompletionWidgetStub(QObject):
"""Stub for the CompletionView."""
def __init__(self, parent=None):
super().__init__(parent)
self.hide = unittest.mock.Mock()
self.show = unittest.mock.Mock()
self.set_pattern = unittest.mock.Mock()
self.model = unittest.mock.Mock()
self.set_model = unittest.mock.Mock()
self.enabled = unittest.mock.Mock()
@pytest.fixture
def completer_obj(qtbot, cmd, config_stub):
def completion_widget_stub():
return CompletionWidgetStub()
@pytest.fixture
def completer_obj(qtbot, status_command_stub, config_stub, monkeypatch, stubs,
completion_widget_stub):
"""Create the completer used for testing."""
monkeypatch.setattr('qutebrowser.completion.completer.QTimer',
stubs.InstaTimer)
config_stub.data = {'completion': {'auto-open': False}}
return completer.Completer(cmd, 0)
return completer.Completer(status_command_stub, 0, completion_widget_stub)
@pytest.fixture(autouse=True)
@ -157,12 +172,12 @@ def _validate_cmd_prompt(cmd, txt):
(':set -t -p |', usertypes.Completion.section),
(':open -- |', None),
])
def test_update_completion(txt, expected, cmd, completer_obj,
def test_update_completion(txt, expected, status_command_stub, 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()
_set_cmd_prompt(status_command_stub, txt)
completer_obj.schedule_completion_update()
if expected is None:
assert not completion_widget_stub.set_model.called
else:
@ -172,24 +187,6 @@ def test_update_completion(txt, expected, cmd, completer_obj,
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|'),
@ -199,10 +196,10 @@ def test_completion_item_next(completer_obj, cmd, completion_widget_stub,
(':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.
def test_on_selection_changed(before, newtxt, count, quick_complete, after,
completer_obj, status_command_stub,
completion_widget_stub, config_stub):
"""Test that on_selection_changed 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),
@ -215,9 +212,10 @@ def test_selection_changed(before, newtxt, count, quick_complete, after,
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)
completion_widget_stub.model.return_value = model
_set_cmd_prompt(status_command_stub, before)
# schedule_completion_update is needed to pick up the cursor position
completer_obj.schedule_completion_update()
completer_obj.on_selection_changed(selection)
model.data.assert_called_with(indexes[0])
_validate_cmd_prompt(cmd, after)
_validate_cmd_prompt(status_command_stub, after)

View File

@ -0,0 +1,147 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2016 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
#
# 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 CompletionView Object."""
import unittest.mock
import pytest
from PyQt5.QtGui import QStandardItem, QColor
from qutebrowser.completion import completionwidget
from qutebrowser.completion.models import base, sortfilter
@pytest.fixture
def completionview(qtbot, status_command_stub, config_stub, win_registry,
mocker):
"""Create the CompletionView used for testing."""
config_stub.data = {
'completion': {
'show': True,
'auto-open': True,
'scrollbar-width': 12,
'scrollbar-padding': 2,
'shrink': False,
},
'colors': {
'completion.fg': QColor(),
'completion.bg': QColor(),
'completion.alternate-bg': QColor(),
'completion.category.fg': QColor(),
'completion.category.bg': QColor(),
'completion.category.border.top': QColor(),
'completion.category.border.bottom': QColor(),
'completion.item.selected.fg': QColor(),
'completion.item.selected.bg': QColor(),
'completion.item.selected.border.top': QColor(),
'completion.item.selected.border.bottom': QColor(),
'completion.match.fg': QColor(),
'completion.scrollbar.fg': QColor(),
'completion.scrollbar.bg': QColor(),
},
'fonts': {
'completion': 'Comic Sans Monospace'
}
}
# mock the Completer that the widget creates in its constructor
mocker.patch('qutebrowser.completion.completer.Completer', autospec=True)
view = completionwidget.CompletionView(win_id=0)
qtbot.addWidget(view)
return view
def test_set_model(completionview):
"""Ensure set_model actually sets the model and expands all categories."""
model = base.BaseCompletionModel()
filtermodel = sortfilter.CompletionFilterModel(model)
for i in range(3):
model.appendRow(QStandardItem(str(i)))
completionview.set_model(filtermodel)
assert completionview.model() is filtermodel
for i in range(model.rowCount()):
assert completionview.isExpanded(filtermodel.index(i, 0))
def test_set_pattern(completionview):
model = sortfilter.CompletionFilterModel(base.BaseCompletionModel())
model.set_pattern = unittest.mock.Mock()
completionview.set_model(model)
completionview.set_pattern('foo')
model.set_pattern.assert_called_with('foo')
def test_maybe_resize_completion(completionview, config_stub, qtbot):
"""Ensure completion is resized only if shrink is True."""
with qtbot.assertNotEmitted(completionview.resize_completion):
completionview.maybe_resize_completion()
config_stub.data = {'completion': {'shrink': True}}
with qtbot.waitSignal(completionview.resize_completion):
completionview.maybe_resize_completion()
@pytest.mark.parametrize('tree, count, expected', [
([['Aa']], 1, 'Aa'),
([['Aa']], -1, 'Aa'),
([['Aa'], ['Ba']], 1, 'Aa'),
([['Aa'], ['Ba']], -1, 'Ba'),
([['Aa'], ['Ba']], 2, 'Ba'),
([['Aa'], ['Ba']], -2, 'Aa'),
([['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 3, 'Ac'),
([['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 4, 'Ba'),
([['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 6, 'Ca'),
([['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 7, 'Aa'),
([['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], -1, 'Ca'),
([['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], -2, 'Bb'),
([['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], -4, 'Ac'),
([[], ['Ba', 'Bb']], 1, 'Ba'),
([[], ['Ba', 'Bb']], -1, 'Bb'),
([[], [], ['Ca', 'Cb']], 1, 'Ca'),
([[], [], ['Ca', 'Cb']], -1, 'Cb'),
([['Aa'], []], 1, 'Aa'),
([['Aa'], []], -1, 'Aa'),
([['Aa'], [], []], 1, 'Aa'),
([['Aa'], [], []], -1, 'Aa'),
])
def test_completion_item_next_prev(tree, count, expected, completionview):
"""Test that on_next_prev_item moves the selection properly.
Args:
tree: Each list represents a completion category, with each string
being an item under that category.
count: Number of times to go forward (or back if negative).
expected: item data that should be selected after going back/forward.
"""
model = base.BaseCompletionModel()
for catdata in tree:
cat = QStandardItem()
model.appendRow(cat)
for name in catdata:
cat.appendRow(QStandardItem(name))
filtermodel = sortfilter.CompletionFilterModel(model,
parent=completionview)
completionview.set_model(filtermodel)
if count < 0:
for _ in range(-count):
completionview.completion_item_prev()
else:
for _ in range(count):
completionview.completion_item_next()
idx = completionview.selectionModel().currentIndex()
assert filtermodel.data(idx) == expected