summaryrefslogtreecommitdiff
path: root/tests/unit/completion/test_completer.py
blob: 2eca889398445b8e1b26581b2d0bb227a8a63f8d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:

# Copyright 2016-2021 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 <https://www.gnu.org/licenses/>.

"""Tests for the Completer Object."""

import unittest.mock

import pytest
from qutebrowser.qt import QtGui, QtCore

from qutebrowser.completion import completer
from qutebrowser.commands import command
from qutebrowser.api import cmdutils


@pytest.fixture(autouse=True)
def setup_cur_tab(tabbed_browser_stubs, fake_web_tab):
    # Make sure completions can access the current tab
    tabbed_browser_stubs[0].widget.tabs = [fake_web_tab()]
    tabbed_browser_stubs[0].widget.current_index = 0


class FakeCompletionModel(QtGui.QStandardItemModel):

    """Stub for a completion model."""

    def __init__(self, kind, *pos_args, info, parent=None):
        super().__init__(parent)
        self.kind = kind
        self.pos_args = list(pos_args)
        self.info = info


class CompletionWidgetStub(QtCore.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 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(completer.QtCore, 'QTimer', stubs.InstaTimer)
    config_stub.val.completion.show = 'auto'
    return completer.Completer(cmd=status_command_stub, win_id=0,
                               parent=completion_widget_stub)


@pytest.fixture(autouse=True)
def miscmodels_patch(mocker):
    """Patch the miscmodels module to provide fake completion functions.

    Technically some of these are not part of miscmodels, but rolling them into
    one module is easier and sufficient for mocking. The only one referenced
    directly by Completer is miscmodels.command.
    """
    m = mocker.patch('qutebrowser.completion.completer.miscmodels',
                     autospec=True)

    def func(name):
        return lambda *args, info: FakeCompletionModel(name, *args, info=info)

    m.command = func('command')
    m.helptopic = func('helptopic')
    m.quickmark = func('quickmark')
    m.bookmark = func('bookmark')
    m.session = func('session')
    m.tabs = func('tabs')
    m.bind = func('bind')
    m.url = func('url')
    m.section = func('section')
    m.option = func('option')
    m.value = func('value')
    return m


@pytest.fixture(autouse=True)
def cmdutils_patch(monkeypatch, stubs, miscmodels_patch):
    """Patch the cmdutils module to provide fake commands."""
    @cmdutils.argument('section_', completion=miscmodels_patch.section)
    @cmdutils.argument('option', completion=miscmodels_patch.option)
    @cmdutils.argument('value', completion=miscmodels_patch.value)
    def set_command(section_=None, option=None, value=None):
        """docstring."""

    @cmdutils.argument('topic', completion=miscmodels_patch.helptopic)
    def show_help(tab=False, bg=False, window=False, topic=None):
        """docstring."""

    @cmdutils.argument('url', completion=miscmodels_patch.url)
    @cmdutils.argument('count', value=cmdutils.Value.count)
    def openurl(url=None, related=False, bg=False, tab=False, window=False,
                count=None):
        """docstring."""

    @cmdutils.argument('win_id', value=cmdutils.Value.win_id)
    @cmdutils.argument('command', completion=miscmodels_patch.command)
    def bind(key, win_id, command=None, *, mode='normal'):
        """docstring."""

    def tab_give():
        """docstring."""

    @cmdutils.argument('option', completion=miscmodels_patch.option)
    @cmdutils.argument('values', completion=miscmodels_patch.value)
    def config_cycle(option, *values):
        """For testing varargs."""

    commands = {
        'set': command.Command(name='set', handler=set_command),
        'help': command.Command(name='help', handler=show_help),
        'open': command.Command(name='open', handler=openurl, maxsplit=0),
        'bind': command.Command(name='bind', handler=bind),
        'tab-give': command.Command(name='tab-give', handler=tab_give),
        'config-cycle': command.Command(name='config-cycle',
                                        handler=config_cycle),
    }
    monkeypatch.setattr(completer.objects, 'commands', commands)


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('|'))


@pytest.mark.parametrize('txt, kind, pattern, pos_args', [
    (':nope|', 'command', 'nope', []),
    (':nope |', None, '', []),
    (':set |', 'section', '', []),
    (':set gen|', 'section', 'gen', []),
    (':set general |', 'option', '', ['general']),
    (':set what |', 'option', '', ['what']),
    (':set general editor |', 'value', '', ['general', 'editor']),
    (':set general editor gv|', 'value', 'gv', ['general', 'editor']),
    (':set general editor "gvim -f"|', 'value', 'gvim -f',
     ['general', 'editor']),
    (':set general editor "gvim |', 'value', 'gvim', ['general', 'editor']),
    (':set general huh |', 'value', '', ['general', 'huh']),
    (':help |', 'helptopic', '', []),
    (':help     |', 'helptopic', '', []),
    (':open |', 'url', '', []),
    (':bind |', None, '', []),
    (':bind <c-x> |', 'command', '', ['<c-x>']),
    (':bind <c-x> foo|', 'command', 'foo', ['<c-x>']),
    (':bind <c-x>| foo', None, '<c-x>', []),
    (':set| general ', 'command', 'set', []),
    (':|set general ', 'command', 'set', []),
    (':set gene|ral ignore-case', 'section', 'general', []),
    (':|', 'command', '', []),
    (':   |', 'command', '', []),
    ('/|', None, '', []),
    (':open -t|', None, '', []),
    (':open --tab|', None, '', []),
    (':open -t |', 'url', '', []),
    (':open --tab |', 'url', '', []),
    (':open | -t', 'url', '', []),
    (':tab-give |', None, '', []),
    (':bind --mode=caret <c-x> |', 'command', '', ['<c-x>']),
    pytest.param(':bind --mode caret <c-x> |', 'command', '', [],
                 marks=pytest.mark.xfail(reason='issue #74')),
    (':set -t -p |', 'section', '', []),
    (':open -- |', None, '', []),
    (':gibberish nonesense |', None, '', []),
    ('/:help|', None, '', []),
    ('::bind|', 'command', ':bind', []),
    (':-w open |', None, '', []),
    # varargs
    (':config-cycle option |', 'value', '', ['option']),
    (':config-cycle option one |', 'value', '', ['option', 'one']),
    (':config-cycle option one two |', 'value', '', ['option', 'one', 'two']),
])
def test_update_completion(txt, kind, pattern, pos_args, status_command_stub,
                           completer_obj, completion_widget_stub, config_stub,
                           key_config_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(status_command_stub, txt)
    completer_obj.schedule_completion_update()
    if kind is None:
        assert not completion_widget_stub.set_pattern.called
    else:
        assert completion_widget_stub.set_model.call_count == 1
        model = completion_widget_stub.set_model.call_args[0][0]
        assert model.kind == kind
        assert model.pos_args == pos_args
        assert model.info.config == config_stub
        assert model.info.keyconf == key_config_stub
        completion_widget_stub.set_pattern.assert_called_once_with(pattern)


@pytest.mark.parametrize('txt1, txt2, regen', [
    (':config-cycle |', ':config-cycle a|', False),
    (':config-cycle abc|', ':config-cycle abc |', True),
    (':config-cycle abc |', ':config-cycle abc d|', False),
    (':config-cycle abc def|', ':config-cycle abc def |', True),
    # open has maxsplit=0, so all args just set the pattern, not the model
    (':open |', ':open a|', False),
    (':open abc|', ':open abc |', False),
    (':open abc |', ':open abc d|', False),
    (':open abc def|', ':open abc def |', False),
])
def test_regen_completion(txt1, txt2, regen, status_command_stub,
                          completer_obj, completion_widget_stub, config_stub,
                          key_config_stub):
    """Test that the completion function is only called as needed."""
    # set the initial state
    _set_cmd_prompt(status_command_stub, txt1)
    completer_obj.schedule_completion_update()
    completion_widget_stub.set_model.reset_mock()

    # "move" the cursor and check if the completion function was called
    _set_cmd_prompt(status_command_stub, txt2)
    completer_obj.schedule_completion_update()
    assert completion_widget_stub.set_model.called == regen


@pytest.mark.parametrize('before, newtxt, after', [
    (':|', 'set', ':set|'),
    (':| ', 'set', ':set|'),
    (': |', 'set', ':set|'),
    (':|set', 'set', ':set|'),
    (':|set ', 'set', ':set|'),
    (':|se', 'set', ':set|'),
    (':|se ', 'set', ':set|'),
    (':s|e', 'set', ':set|'),
    (':se|', 'set', ':set|'),
    (':|se fonts', 'set', ':set| fonts'),
    (':set |', 'fonts', ':set fonts|'),
    (':set  |', 'fonts', ':set fonts|'),
    (':set --temp |', 'fonts', ':set --temp fonts|'),
    (':set |fo', 'fonts', ':set fonts|'),
    (':set f|o', 'fonts', ':set fonts|'),
    (':set fo|', 'fonts', ':set fonts|'),
    (':set fonts |', 'hints', ':set fonts hints|'),
    (':set fonts |nt', 'hints', ':set fonts hints|'),
    (':set fonts n|t', 'hints', ':set fonts hints|'),
    (':set fonts nt|', 'hints', ':set fonts hints|'),
    (':set | hints', 'fonts', ':set fonts| hints'),
    (':set  |  hints', 'fonts', ':set fonts| hints'),
    (':set |fo hints', 'fonts', ':set fonts| hints'),
    (':set f|o hints', 'fonts', ':set fonts| hints'),
    (':set fo| hints', 'fonts', ':set fonts| hints'),
    (':set fonts hints |', 'Comic Sans', ":set fonts hints 'Comic Sans'|"),
    (":set fonts hints 'Comic Sans'|", '12px Hack',
     ":set fonts hints '12px Hack'|"),
    (":set fonts hints 'Comic| Sans'", '12px Hack',
     ":set fonts hints '12px Hack'|"),
    # Make sure " is quoted properly
    (':set url.start_pages \'["https://www.|example.com"]\'',
     '["https://www.example.org"]',
     ':set url.start_pages \'["https://www.example.org"]\'|'),
    # open has maxsplit=0, so treat the last two tokens as one and don't quote
    (':open foo bar|', 'baz', ':open baz|'),
    (':open foo| bar', 'baz', ':open baz|'),
])
def test_on_selection_changed(before, newtxt, after, completer_obj,
                              config_stub, status_command_stub,
                              completion_widget_stub):
    """Test that on_selection_changed modifies the cmd text properly.

    The | represents the current cursor position in the cmd prompt.
    If quick is True and there is only 1 completion (count == 1),
    then we expect a space to be appended after the current word.
    """
    model = unittest.mock.Mock()
    completion_widget_stub.model.return_value = model

    def check(quick, count, expected_txt, expected_pos):
        config_stub.val.completion.quick = quick
        model.count = lambda: count
        _set_cmd_prompt(status_command_stub, before)
        completer_obj.on_selection_changed(newtxt)
        assert status_command_stub.text() == expected_txt
        assert status_command_stub.cursorPosition() == expected_pos

    after_pos = after.index('|')
    after_txt = after.replace('|', '')
    check(False, 1, after_txt, after_pos)
    check(True, 2, after_txt, after_pos)

    # quick-completing a single item should move the cursor ahead by 1 and add
    # a trailing space if at the end of the cmd string, unless the command has
    # maxsplit < len(before) (such as :open in these tests)
    if after_txt.startswith(':open'):
        return

    after_pos += 1
    if after_pos > len(after_txt):
        after_txt += ' '
    check(True, 1, after_txt, after_pos)


def test_quickcomplete_flicker(status_command_stub, completer_obj,
                               completion_widget_stub, config_stub):
    """Validate fix for #1519: bookmark-load background highlighting quirk.

    For commands like bookmark-load and open with maxsplit=0, a commandline
    that looks like ':open someurl |' is considered to be completing the first
    arg with pattern 'someurl ' (note trailing whitespace). As this matches the
    one completion available, it keeps the completionmenu open.

    This test validates that the completion model is not re-set after we
    quick-complete an entry after maxsplit.
    """
    model = unittest.mock.Mock()
    model.count = unittest.mock.Mock(return_value=1)
    completion_widget_stub.model.return_value = model
    config_stub.val.completion.quick = True

    _set_cmd_prompt(status_command_stub, ':open |')
    completer_obj.schedule_completion_update()
    assert completion_widget_stub.set_model.called
    completion_widget_stub.set_model.reset_mock()

    # selecting a completion should not re-set the model
    completer_obj.on_selection_changed('http://example.com')
    completer_obj.schedule_completion_update()
    assert not completion_widget_stub.set_model.called


def test_min_chars(status_command_stub, completer_obj, completion_widget_stub,
                   config_stub, key_config_stub):
    """Test that an update is delayed until min_chars characters are input."""
    config_stub.val.completion.min_chars = 3

    # Test #3635, where min_chars could crash the first update
    _set_cmd_prompt(status_command_stub, ':set c|')
    completer_obj.schedule_completion_update()
    assert not completion_widget_stub.set_model.called

    _set_cmd_prompt(status_command_stub, ':set co|')
    completer_obj.schedule_completion_update()
    assert not completion_widget_stub.set_model.called

    _set_cmd_prompt(status_command_stub, ':set com|')
    completer_obj.schedule_completion_update()
    assert completion_widget_stub.set_model.call_count == 1