Decouple categories from completionmodel.
Instead of add_list and add_sqltable, the completion model now supports add_category, and callees either pass in a SqlCategory or ListCategory. This makes unit testing much easier. This also folds CompletionFilterModel into the ListCategory class.
This commit is contained in:
parent
ce3c555712
commit
f95dff4d9e
@ -41,7 +41,7 @@ from qutebrowser.utils import (message, usertypes, log, qtutils, urlutils,
|
||||
objreg, utils, typing)
|
||||
from qutebrowser.utils.usertypes import KeyMode
|
||||
from qutebrowser.misc import editor, guiprocess
|
||||
from qutebrowser.completion.models import sortfilter, urlmodel, miscmodels
|
||||
from qutebrowser.completion.models import urlmodel, miscmodels
|
||||
|
||||
|
||||
class CommandDispatcher:
|
||||
@ -1024,10 +1024,9 @@ class CommandDispatcher:
|
||||
int(part)
|
||||
except ValueError:
|
||||
model = miscmodels.buffer()
|
||||
sf = sortfilter.CompletionFilterModel(source=model)
|
||||
sf.set_pattern(index)
|
||||
if sf.count() > 0:
|
||||
index = sf.data(sf.first_item())
|
||||
model.set_pattern(index)
|
||||
if model.count() > 0:
|
||||
index = model.data(model.first_item())
|
||||
index_parts = index.split('/', 1)
|
||||
else:
|
||||
raise cmdexc.CommandError(
|
||||
|
@ -275,6 +275,7 @@ class CompletionView(QTreeView):
|
||||
self.hide()
|
||||
return
|
||||
|
||||
model.setParent(self)
|
||||
old_model = self.model()
|
||||
if model is not old_model:
|
||||
sel_model = self.selectionModel()
|
||||
|
@ -24,7 +24,6 @@ import re
|
||||
from PyQt5.QtCore import Qt, QModelIndex, QAbstractItemModel
|
||||
|
||||
from qutebrowser.utils import log, qtutils
|
||||
from qutebrowser.completion.models import sortfilter, listcategory, sqlcategory
|
||||
|
||||
|
||||
class CompletionModel(QAbstractItemModel):
|
||||
@ -65,33 +64,8 @@ class CompletionModel(QAbstractItemModel):
|
||||
return self._categories[index.row()]
|
||||
return None
|
||||
|
||||
def add_list(self, name, items):
|
||||
"""Add a list of items as a completion category.
|
||||
|
||||
Args:
|
||||
name: Title of the category.
|
||||
items: List of tuples.
|
||||
"""
|
||||
cat = listcategory.ListCategory(name, items, parent=self)
|
||||
filtermodel = sortfilter.CompletionFilterModel(cat,
|
||||
self.columns_to_filter)
|
||||
self._categories.append(filtermodel)
|
||||
|
||||
def add_sqltable(self, name, *, select='*', where=None, sort_by=None,
|
||||
sort_order=None):
|
||||
"""Create a new completion category and add it to this model.
|
||||
|
||||
Args:
|
||||
name: Name of category, and the table in the database.
|
||||
select: A custom result column expression for the select statement.
|
||||
where: An optional clause to filter out some rows.
|
||||
sort_by: The name of the field to sort by, or None for no sorting.
|
||||
sort_order: Either 'asc' or 'desc', if sort_by is non-None
|
||||
"""
|
||||
cat = sqlcategory.SqlCategory(name, parent=self, sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
select=select, where=where,
|
||||
columns_to_filter=self.columns_to_filter)
|
||||
def add_category(self, cat):
|
||||
"""Add a completion category to the model."""
|
||||
self._categories.append(cat)
|
||||
|
||||
def data(self, index, role=Qt.DisplayRole):
|
||||
@ -210,7 +184,7 @@ class CompletionModel(QAbstractItemModel):
|
||||
# TODO: should pattern be saved in the view layer instead?
|
||||
self.pattern = pattern
|
||||
for cat in self._categories:
|
||||
cat.set_pattern(pattern)
|
||||
cat.set_pattern(pattern, self.columns_to_filter)
|
||||
|
||||
def first_item(self):
|
||||
"""Return the index of the first child (non-category) in the model."""
|
||||
|
@ -20,7 +20,7 @@
|
||||
"""Functions that return config-related completion models."""
|
||||
|
||||
from qutebrowser.config import configdata, configexc
|
||||
from qutebrowser.completion.models import completionmodel
|
||||
from qutebrowser.completion.models import completionmodel, listcategory
|
||||
from qutebrowser.utils import objreg
|
||||
|
||||
|
||||
@ -29,7 +29,7 @@ def section():
|
||||
model = completionmodel.CompletionModel(column_widths=(20, 70, 10))
|
||||
sections = ((name, configdata.SECTION_DESC[name].splitlines()[0].strip())
|
||||
for name in configdata.DATA)
|
||||
model.add_list("Sections", sections)
|
||||
model.add_category(listcategory.ListCategory("Sections", sections))
|
||||
return model
|
||||
|
||||
|
||||
@ -57,7 +57,7 @@ def option(sectname):
|
||||
config = objreg.get('config')
|
||||
val = config.get(sectname, name, raw=True)
|
||||
options.append((name, desc, val))
|
||||
model.add_list(sectname, options)
|
||||
model.add_category(listcategory.ListCategory(sectname, options))
|
||||
return model
|
||||
|
||||
|
||||
@ -88,8 +88,9 @@ def value(sectname, optname):
|
||||
# Different type for each value (KeyValue)
|
||||
vals = configdata.DATA[sectname][optname].typ.complete()
|
||||
|
||||
model.add_list("Current/Default", [(current, "Current value"),
|
||||
(default, "Default value")])
|
||||
cur_cat = listcategory.ListCategory("Current/Default",
|
||||
[(current, "Current value"), (default, "Default value")])
|
||||
model.add_category(cur_cat)
|
||||
if vals is not None:
|
||||
model.add_list("Completions", vals)
|
||||
model.add_category(listcategory.ListCategory("Completions", vals))
|
||||
return model
|
||||
|
@ -23,44 +23,100 @@ Module attributes:
|
||||
Role: An enum of user defined model roles.
|
||||
"""
|
||||
|
||||
from PyQt5.QtCore import Qt
|
||||
import re
|
||||
|
||||
from PyQt5.QtCore import Qt, QSortFilterProxyModel, QModelIndex
|
||||
from PyQt5.QtGui import QStandardItem, QStandardItemModel
|
||||
|
||||
from qutebrowser.utils import qtutils, debug, log
|
||||
|
||||
class ListCategory(QStandardItemModel):
|
||||
|
||||
class ListCategory(QSortFilterProxyModel):
|
||||
|
||||
"""Expose a list of items as a category for the CompletionModel."""
|
||||
|
||||
def __init__(self, name, items, parent=None):
|
||||
super().__init__(parent)
|
||||
self.name = name
|
||||
# self.setColumnCount(3) TODO needed?
|
||||
# TODO: batch insert?
|
||||
# TODO: can I just insert a tuple instead of a list?
|
||||
self.srcmodel = QStandardItemModel(parent=self)
|
||||
self.pattern = ''
|
||||
self.pattern_re = None
|
||||
for item in items:
|
||||
self.appendRow([QStandardItem(x) for x in item])
|
||||
self.srcmodel.appendRow([QStandardItem(x) for x in item])
|
||||
self.setSourceModel(self.srcmodel)
|
||||
|
||||
def flags(self, index):
|
||||
"""Return the item flags for index.
|
||||
|
||||
Override QAbstractItemModel::flags.
|
||||
def set_pattern(self, val, columns_to_filter):
|
||||
"""Setter for pattern.
|
||||
|
||||
Args:
|
||||
index: The QModelIndex to get item flags for.
|
||||
val: The value to set.
|
||||
"""
|
||||
with debug.log_time(log.completion, 'Setting filter pattern'):
|
||||
self.columns_to_filter = columns_to_filter
|
||||
self.pattern = val
|
||||
val = re.sub(r' +', r' ', val) # See #1919
|
||||
val = re.escape(val)
|
||||
val = val.replace(r'\ ', '.*')
|
||||
self.pattern_re = re.compile(val, re.IGNORECASE)
|
||||
self.invalidate()
|
||||
sortcol = 0
|
||||
self.sort(sortcol)
|
||||
|
||||
def filterAcceptsRow(self, row, parent):
|
||||
"""Custom filter implementation.
|
||||
|
||||
Override QSortFilterProxyModel::filterAcceptsRow.
|
||||
|
||||
Args:
|
||||
row: The row of the item.
|
||||
parent: The parent item QModelIndex.
|
||||
|
||||
Return:
|
||||
The item flags, or Qt.NoItemFlags on error.
|
||||
True if self.pattern is contained in item, or if it's a root item
|
||||
(category). False in all other cases
|
||||
"""
|
||||
if not index.isValid():
|
||||
return
|
||||
if not self.pattern:
|
||||
return True
|
||||
|
||||
if index.parent().isValid():
|
||||
# item
|
||||
return (Qt.ItemIsEnabled | Qt.ItemIsSelectable |
|
||||
Qt.ItemNeverHasChildren)
|
||||
for col in self.columns_to_filter:
|
||||
idx = self.srcmodel.index(row, col, parent)
|
||||
if not idx.isValid(): # pragma: no cover
|
||||
# this is a sanity check not hit by any test case
|
||||
continue
|
||||
data = self.srcmodel.data(idx)
|
||||
if not data:
|
||||
continue
|
||||
elif self.pattern_re.search(data):
|
||||
return True
|
||||
return False
|
||||
|
||||
def lessThan(self, lindex, rindex):
|
||||
"""Custom sorting implementation.
|
||||
|
||||
Prefers all items which start with self.pattern. Other than that, uses
|
||||
normal Python string sorting.
|
||||
|
||||
Args:
|
||||
lindex: The QModelIndex of the left item (*left* < right)
|
||||
rindex: The QModelIndex of the right item (left < *right*)
|
||||
|
||||
Return:
|
||||
True if left < right, else False
|
||||
"""
|
||||
qtutils.ensure_valid(lindex)
|
||||
qtutils.ensure_valid(rindex)
|
||||
|
||||
left = self.srcmodel.data(lindex)
|
||||
right = self.srcmodel.data(rindex)
|
||||
|
||||
leftstart = left.startswith(self.pattern)
|
||||
rightstart = right.startswith(self.pattern)
|
||||
|
||||
if leftstart and rightstart:
|
||||
return left < right
|
||||
elif leftstart:
|
||||
return True
|
||||
elif rightstart:
|
||||
return False
|
||||
else:
|
||||
# category
|
||||
return Qt.NoItemFlags
|
||||
|
||||
def set_pattern(self, pattern):
|
||||
pass
|
||||
return left < right
|
||||
|
@ -22,14 +22,14 @@
|
||||
from qutebrowser.config import config, configdata
|
||||
from qutebrowser.utils import objreg, log, qtutils
|
||||
from qutebrowser.commands import cmdutils
|
||||
from qutebrowser.completion.models import completionmodel
|
||||
from qutebrowser.completion.models import completionmodel, listcategory
|
||||
|
||||
|
||||
def command():
|
||||
"""A CompletionModel filled with non-hidden commands and descriptions."""
|
||||
model = completionmodel.CompletionModel(column_widths=(20, 60, 20))
|
||||
cmdlist = _get_cmd_completions(include_aliases=True, include_hidden=False)
|
||||
model.add_list("Commands", cmdlist)
|
||||
model.add_category(listcategory.ListCategory("Commands", cmdlist))
|
||||
return model
|
||||
|
||||
|
||||
@ -53,22 +53,24 @@ def helptopic():
|
||||
name = '{}->{}'.format(sectname, optname)
|
||||
settings.append((name, desc))
|
||||
|
||||
model.add_list("Commands", cmdlist)
|
||||
model.add_list("Settings", settings)
|
||||
model.add_category(listcategory.ListCategory("Commands", cmdlist))
|
||||
model.add_category(listcategory.ListCategory("Settings", settings))
|
||||
return model
|
||||
|
||||
|
||||
def quickmark():
|
||||
"""A CompletionModel filled with all quickmarks."""
|
||||
model = completionmodel.CompletionModel(column_widths=(30, 70, 0))
|
||||
model.add_list('Quickmarks', objreg.get('quickmark-manager').marks.items())
|
||||
marks = objreg.get('quickmark-manager').marks.items()
|
||||
model.add_category(listcategory.ListCategory('Quickmarks', marks))
|
||||
return model
|
||||
|
||||
|
||||
def bookmark():
|
||||
"""A CompletionModel filled with all bookmarks."""
|
||||
model = completionmodel.CompletionModel(column_widths=(30, 70, 0))
|
||||
model.add_list('Bookmarks', objreg.get('bookmark-manager').marks.items())
|
||||
marks = objreg.get('bookmark-manager').marks.items()
|
||||
model.add_category(listcategory.ListCategory('Bookmarks', marks))
|
||||
return model
|
||||
|
||||
|
||||
@ -79,7 +81,7 @@ def session():
|
||||
manager = objreg.get('session-manager')
|
||||
sessions = ((name,) for name in manager.list_sessions()
|
||||
if not name.startswith('_'))
|
||||
model.add_list("Sessions", sessions)
|
||||
model.add_category(listcategory.ListCategory("Sessions", sessions))
|
||||
except OSError:
|
||||
log.completion.exception("Failed to list sessions!")
|
||||
return model
|
||||
@ -122,7 +124,8 @@ def buffer():
|
||||
tabs.append(("{}/{}".format(win_id, idx + 1),
|
||||
tab.url().toDisplayString(),
|
||||
tabbed_browser.page_title(idx)))
|
||||
model.add_list("{}".format(win_id), tabs)
|
||||
cat = listcategory.ListCategory("{}".format(win_id), tabs)
|
||||
model.add_category(cat)
|
||||
return model
|
||||
|
||||
|
||||
@ -135,7 +138,7 @@ def bind(_key):
|
||||
# TODO: offer a 'Current binding' completion based on the key.
|
||||
model = completionmodel.CompletionModel(column_widths=(20, 60, 20))
|
||||
cmdlist = _get_cmd_completions(include_hidden=True, include_aliases=True)
|
||||
model.add_list("Commands", cmdlist)
|
||||
model.add_category(listcategory.ListCategory("Commands", cmdlist))
|
||||
return model
|
||||
|
||||
|
||||
|
@ -1,133 +0,0 @@
|
||||
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
|
||||
|
||||
# Copyright 2014-2017 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/>.
|
||||
|
||||
"""A filtering/sorting base model for completions.
|
||||
|
||||
Contains:
|
||||
CompletionFilterModel -- A QSortFilterProxyModel subclass for completions.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from PyQt5.QtCore import QSortFilterProxyModel, QModelIndex
|
||||
|
||||
from qutebrowser.utils import log, qtutils, debug
|
||||
|
||||
|
||||
class CompletionFilterModel(QSortFilterProxyModel):
|
||||
|
||||
"""Subclass of QSortFilterProxyModel with custom filtering.
|
||||
|
||||
Attributes:
|
||||
pattern: The pattern to filter with.
|
||||
srcmodel: The current source model.
|
||||
Kept as attribute because calling `sourceModel` takes quite
|
||||
a long time for some reason.
|
||||
"""
|
||||
|
||||
def __init__(self, source, columns_to_filter, parent=None):
|
||||
super().__init__(parent)
|
||||
super().setSourceModel(source)
|
||||
self.srcmodel = source
|
||||
self.pattern = ''
|
||||
self.pattern_re = None
|
||||
self.columns_to_filter = columns_to_filter
|
||||
self.name = source.name
|
||||
|
||||
def set_pattern(self, val):
|
||||
"""Setter for pattern.
|
||||
|
||||
Args:
|
||||
val: The value to set.
|
||||
"""
|
||||
with debug.log_time(log.completion, 'Setting filter pattern'):
|
||||
self.pattern = val
|
||||
val = re.sub(r' +', r' ', val) # See #1919
|
||||
val = re.escape(val)
|
||||
val = val.replace(r'\ ', '.*')
|
||||
self.pattern_re = re.compile(val, re.IGNORECASE)
|
||||
self.invalidate()
|
||||
sortcol = 0
|
||||
self.sort(sortcol)
|
||||
|
||||
def setSourceModel(self, model):
|
||||
"""Override QSortFilterProxyModel's setSourceModel to clear pattern."""
|
||||
log.completion.debug("Setting source model: {}".format(model))
|
||||
self.set_pattern('')
|
||||
super().setSourceModel(model)
|
||||
self.srcmodel = model
|
||||
|
||||
def filterAcceptsRow(self, row, parent):
|
||||
"""Custom filter implementation.
|
||||
|
||||
Override QSortFilterProxyModel::filterAcceptsRow.
|
||||
|
||||
Args:
|
||||
row: The row of the item.
|
||||
parent: The parent item QModelIndex.
|
||||
|
||||
Return:
|
||||
True if self.pattern is contained in item, or if it's a root item
|
||||
(category). False in all other cases
|
||||
"""
|
||||
if not self.pattern:
|
||||
return True
|
||||
|
||||
for col in self.columns_to_filter:
|
||||
idx = self.srcmodel.index(row, col, parent)
|
||||
if not idx.isValid(): # pragma: no cover
|
||||
# this is a sanity check not hit by any test case
|
||||
continue
|
||||
data = self.srcmodel.data(idx)
|
||||
if not data:
|
||||
continue
|
||||
elif self.pattern_re.search(data):
|
||||
return True
|
||||
return False
|
||||
|
||||
def lessThan(self, lindex, rindex):
|
||||
"""Custom sorting implementation.
|
||||
|
||||
Prefers all items which start with self.pattern. Other than that, uses
|
||||
normal Python string sorting.
|
||||
|
||||
Args:
|
||||
lindex: The QModelIndex of the left item (*left* < right)
|
||||
rindex: The QModelIndex of the right item (left < *right*)
|
||||
|
||||
Return:
|
||||
True if left < right, else False
|
||||
"""
|
||||
qtutils.ensure_valid(lindex)
|
||||
qtutils.ensure_valid(rindex)
|
||||
|
||||
left = self.srcmodel.data(lindex)
|
||||
right = self.srcmodel.data(rindex)
|
||||
|
||||
leftstart = left.startswith(self.pattern)
|
||||
rightstart = right.startswith(self.pattern)
|
||||
|
||||
if leftstart and rightstart:
|
||||
return left < right
|
||||
elif leftstart:
|
||||
return True
|
||||
elif rightstart:
|
||||
return False
|
||||
else:
|
||||
return left < right
|
@ -29,36 +29,48 @@ from qutebrowser.misc import sql
|
||||
class SqlCategory(QSqlQueryModel):
|
||||
"""Wraps a SqlQuery for use as a completion category."""
|
||||
|
||||
def __init__(self, name, *, sort_by, sort_order, select, where,
|
||||
columns_to_filter, parent=None):
|
||||
def __init__(self, name, *, sort_by=None, sort_order=None, select='*',
|
||||
where=None, parent=None):
|
||||
"""Create a new completion category backed by a sql table.
|
||||
|
||||
Args:
|
||||
name: Name of category, and the table in the database.
|
||||
select: A custom result column expression for the select statement.
|
||||
where: An optional clause to filter out some rows.
|
||||
sort_by: The name of the field to sort by, or None for no sorting.
|
||||
sort_order: Either 'asc' or 'desc', if sort_by is non-None
|
||||
"""
|
||||
super().__init__(parent=parent)
|
||||
self.name = name
|
||||
self._sort_by = sort_by
|
||||
self._sort_order = sort_order
|
||||
self._select = select
|
||||
self._where = where
|
||||
self.set_pattern('', [0])
|
||||
|
||||
query = sql.run_query('select * from {} limit 1'.format(name))
|
||||
self._fields = [query.record().fieldName(i) for i in columns_to_filter]
|
||||
def set_pattern(self, pattern, columns_to_filter):
|
||||
query = sql.run_query('select * from {} limit 1'.format(self.name))
|
||||
fields = [query.record().fieldName(i) for i in columns_to_filter]
|
||||
|
||||
querystr = 'select {} from {} where ('.format(select, name)
|
||||
querystr = 'select {} from {} where ('.format(self._select, self.name)
|
||||
# the incoming pattern will have literal % and _ escaped with '\'
|
||||
# we need to tell sql to treat '\' as an escape character
|
||||
querystr += ' or '.join("{} like ? escape '\\'".format(f)
|
||||
for f in self._fields)
|
||||
for f in fields)
|
||||
querystr += ')'
|
||||
if where:
|
||||
querystr += ' and ' + where
|
||||
if self._where:
|
||||
querystr += ' and ' + self._where
|
||||
|
||||
if sort_by:
|
||||
assert sort_order == 'asc' or sort_order == 'desc'
|
||||
querystr += ' order by {} {}'.format(sort_by, sort_order)
|
||||
if self._sort_by:
|
||||
assert self._sort_order in ['asc', 'desc']
|
||||
querystr += ' order by {} {}'.format(self._sort_by,
|
||||
self._sort_order)
|
||||
|
||||
self._querystr = querystr
|
||||
self.set_pattern('')
|
||||
|
||||
def set_pattern(self, pattern):
|
||||
# escape to treat a user input % or _ as a literal, not a wildcard
|
||||
pattern = pattern.replace('%', '\\%')
|
||||
pattern = pattern.replace('_', '\\_')
|
||||
# treat spaces as wildcards to match any of the typed words
|
||||
pattern = re.sub(r' +', '%', pattern)
|
||||
pattern = '%{}%'.format(pattern)
|
||||
query = sql.run_query(self._querystr, [pattern for _ in self._fields])
|
||||
query = sql.run_query(querystr, [pattern for _ in fields])
|
||||
self.setQuery(query)
|
||||
|
@ -19,7 +19,8 @@
|
||||
|
||||
"""Function to return the url completion model for the `open` command."""
|
||||
|
||||
from qutebrowser.completion.models import completionmodel
|
||||
from qutebrowser.completion.models import (completionmodel, listcategory,
|
||||
sqlcategory)
|
||||
from qutebrowser.config import config
|
||||
from qutebrowser.utils import qtutils, log, objreg
|
||||
|
||||
@ -46,8 +47,7 @@ def _delete_url(completion):
|
||||
log.completion.debug('Deleting bookmark {}'.format(urlstr))
|
||||
bookmark_manager = objreg.get('bookmark-manager')
|
||||
bookmark_manager.delete(urlstr)
|
||||
else:
|
||||
assert catname == 'Quickmarks', 'Unknown category {}'.format(catname)
|
||||
elif catname == 'Quickmarks':
|
||||
quickmark_manager = objreg.get('quickmark-manager')
|
||||
sibling = index.sibling(index.row(), _TEXTCOL)
|
||||
qtutils.ensure_valid(sibling)
|
||||
@ -66,15 +66,17 @@ def url():
|
||||
columns_to_filter=[_URLCOL, _TEXTCOL],
|
||||
delete_cur_item=_delete_url)
|
||||
|
||||
quickmarks = objreg.get('quickmark-manager').marks.items()
|
||||
model.add_list('Quickmarks', ((url, name) for (name, url) in quickmarks))
|
||||
quickmarks = ((url, name) for (name, url)
|
||||
in objreg.get('quickmark-manager').marks.items())
|
||||
bookmarks = objreg.get('bookmark-manager').marks.items()
|
||||
|
||||
model.add_list('Bookmarks', objreg.get('bookmark-manager').marks.items())
|
||||
model.add_category(listcategory.ListCategory('Quickmarks', quickmarks))
|
||||
model.add_category(listcategory.ListCategory('Bookmarks', bookmarks))
|
||||
|
||||
timefmt = config.get('completion', 'timestamp-format')
|
||||
select_time = "strftime('{}', atime, 'unixepoch')".format(timefmt)
|
||||
model.add_sqltable('History',
|
||||
sort_order='desc', sort_by='atime',
|
||||
select='url, title, {}'.format(select_time),
|
||||
where='not redirect')
|
||||
hist_cat = sqlcategory.SqlCategory(
|
||||
'History', sort_order='desc', sort_by='atime',
|
||||
select='url, title, {}'.format(select_time), where='not redirect')
|
||||
model.add_category(hist_cat)
|
||||
return model
|
||||
|
@ -158,8 +158,6 @@ PERFECT_FILES = [
|
||||
'completion/models/base.py'),
|
||||
('tests/unit/completion/test_models.py',
|
||||
'completion/models/urlmodel.py'),
|
||||
('tests/unit/completion/test_sortfilter.py',
|
||||
'completion/models/sortfilter.py'),
|
||||
|
||||
]
|
||||
|
||||
|
@ -239,6 +239,24 @@ def host_blocker_stub(stubs):
|
||||
objreg.delete('host-blocker')
|
||||
|
||||
|
||||
@pytest.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.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.fixture
|
||||
def session_manager_stub(stubs):
|
||||
"""Fixture which provides a fake web-history object."""
|
||||
|
@ -193,11 +193,10 @@ def test_update_completion(txt, kind, pattern, pos_args, status_command_stub,
|
||||
completer_obj.schedule_completion_update()
|
||||
assert completion_widget_stub.set_model.call_count == 1
|
||||
args = completion_widget_stub.set_model.call_args[0]
|
||||
# the outer model is just for sorting; srcmodel is the completion model
|
||||
if kind is None:
|
||||
assert args[0] is None
|
||||
else:
|
||||
model = args[0].srcmodel
|
||||
model = args[0]
|
||||
assert model.kind == kind
|
||||
assert model.pos_args == pos_args
|
||||
assert args[1] == pattern
|
||||
|
@ -19,128 +19,59 @@
|
||||
|
||||
"""Tests for CompletionModel."""
|
||||
|
||||
import sys
|
||||
import pytest
|
||||
import hypothesis
|
||||
from unittest import mock
|
||||
from hypothesis import strategies
|
||||
|
||||
from qutebrowser.completion.models import completionmodel, sortfilter
|
||||
from qutebrowser.completion.models import completionmodel
|
||||
|
||||
|
||||
def _create_model(data, filter_cols=None):
|
||||
"""Create a completion model populated with the given data.
|
||||
|
||||
data: A list of lists, where each sub-list represents a category, each
|
||||
tuple in the sub-list represents an item, and each value in the
|
||||
tuple represents the item data for that column
|
||||
filter_cols: Columns to filter, or None for default.
|
||||
"""
|
||||
model = completionmodel.CompletionModel(columns_to_filter=filter_cols)
|
||||
for catdata in data:
|
||||
model.add_list('', catdata)
|
||||
return model
|
||||
@hypothesis.given(strategies.lists(min_size=0, max_size=3,
|
||||
elements=strategies.integers(min_value=0, max_value=2**31)))
|
||||
def test_first_last_item(counts):
|
||||
"""Test that first() and last() index to the first and last items."""
|
||||
model = completionmodel.CompletionModel()
|
||||
for c in counts:
|
||||
cat = mock.Mock()
|
||||
cat.rowCount = mock.Mock(return_value=c)
|
||||
model.add_category(cat)
|
||||
nonempty = [i for i, rowCount in enumerate(counts) if rowCount > 0]
|
||||
if not nonempty:
|
||||
# with no items, first and last should be an invalid index
|
||||
assert not model.first_item().isValid()
|
||||
assert not model.last_item().isValid()
|
||||
else:
|
||||
first = nonempty[0]
|
||||
last = nonempty[-1]
|
||||
# first item of the first nonempty category
|
||||
assert model.first_item().row() == 0
|
||||
assert model.first_item().parent().row() == first
|
||||
# last item of the last nonempty category
|
||||
assert model.last_item().row() == counts[last] - 1
|
||||
assert model.last_item().parent().row() == last
|
||||
|
||||
|
||||
def _extract_model_data(model):
|
||||
"""Express a model's data as a list for easier comparison.
|
||||
|
||||
Return: A list of lists, where each sub-list represents a category, each
|
||||
tuple in the sub-list represents an item, and each value in the
|
||||
tuple represents the item data for that column
|
||||
"""
|
||||
data = []
|
||||
for i in range(0, model.rowCount()):
|
||||
cat_idx = model.index(i, 0)
|
||||
row = []
|
||||
for j in range(0, model.rowCount(cat_idx)):
|
||||
row.append((model.data(cat_idx.child(j, 0)),
|
||||
model.data(cat_idx.child(j, 1)),
|
||||
model.data(cat_idx.child(j, 2))))
|
||||
data.append(row)
|
||||
return data
|
||||
@hypothesis.given(strategies.lists(elements=strategies.integers(),
|
||||
min_size=0, max_size=3))
|
||||
def test_count(counts):
|
||||
model = completionmodel.CompletionModel()
|
||||
for c in counts:
|
||||
cat = mock.Mock(spec=['rowCount'])
|
||||
cat.rowCount = mock.Mock(return_value=c)
|
||||
model.add_category(cat)
|
||||
assert model.count() == sum(counts)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('tree, first, last', [
|
||||
([[('Aa',)]], 'Aa', 'Aa'),
|
||||
([[('Aa',)], [('Ba',)]], 'Aa', 'Ba'),
|
||||
([[('Aa',), ('Ab',), ('Ac',)], [('Ba',), ('Bb',)], [('Ca',)]],
|
||||
'Aa', 'Ca'),
|
||||
([[], [('Ba',)]], 'Ba', 'Ba'),
|
||||
([[], [], [('Ca',)]], 'Ca', 'Ca'),
|
||||
([[], [], [('Ca',), ('Cb',)]], 'Ca', 'Cb'),
|
||||
([[('Aa',)], []], 'Aa', 'Aa'),
|
||||
([[('Aa',)], []], 'Aa', 'Aa'),
|
||||
([[('Aa',)], [], []], 'Aa', 'Aa'),
|
||||
([[('Aa',)], [], [('Ca',)]], 'Aa', 'Ca'),
|
||||
([[], []], None, None),
|
||||
])
|
||||
def test_first_last_item(tree, first, last):
|
||||
"""Test that first() and last() return indexes to the first and last items.
|
||||
|
||||
Args:
|
||||
tree: Each list represents a completion category, with each string
|
||||
being an item under that category.
|
||||
first: text of the first item
|
||||
last: text of the last item
|
||||
"""
|
||||
model = _create_model(tree)
|
||||
assert model.data(model.first_item()) == first
|
||||
assert model.data(model.last_item()) == last
|
||||
|
||||
|
||||
@pytest.mark.parametrize('tree, expected', [
|
||||
([[('Aa',)]], 1),
|
||||
([[('Aa',)], [('Ba',)]], 2),
|
||||
([[('Aa',), ('Ab',), ('Ac',)], [('Ba',), ('Bb',)], [('Ca',)]], 6),
|
||||
([[], [('Ba',)]], 1),
|
||||
([[], [], [('Ca',)]], 1),
|
||||
([[], [], [('Ca',), ('Cb',)]], 2),
|
||||
([[('Aa',)], []], 1),
|
||||
([[('Aa',)], []], 1),
|
||||
([[('Aa',)], [], []], 1),
|
||||
([[('Aa',)], [], [('Ca',)]], 2),
|
||||
])
|
||||
def test_count(tree, expected):
|
||||
model = _create_model(tree)
|
||||
assert model.count() == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize('pattern, filter_cols, before, after', [
|
||||
('foo', [0],
|
||||
[[('foo', '', ''), ('bar', '', '')]],
|
||||
[[('foo', '', '')]]),
|
||||
|
||||
('foo', [0],
|
||||
[[('foob', '', ''), ('fooc', '', ''), ('fooa', '', '')]],
|
||||
[[('fooa', '', ''), ('foob', '', ''), ('fooc', '', '')]]),
|
||||
|
||||
('foo', [0],
|
||||
[[('foo', '', '')], [('bar', '', '')]],
|
||||
[[('foo', '', '')], []]),
|
||||
|
||||
# prefer foobar as it starts with the pattern
|
||||
('foo', [0],
|
||||
[[('barfoo', '', ''), ('foobar', '', '')]],
|
||||
[[('foobar', '', ''), ('barfoo', '', '')]]),
|
||||
|
||||
# however, don't rearrange categories
|
||||
('foo', [0],
|
||||
[[('barfoo', '', '')], [('foobar', '', '')]],
|
||||
[[('barfoo', '', '')], [('foobar', '', '')]]),
|
||||
|
||||
('foo', [1],
|
||||
[[('foo', 'bar', ''), ('bar', 'foo', '')]],
|
||||
[[('bar', 'foo', '')]]),
|
||||
|
||||
('foo', [0, 1],
|
||||
[[('foo', 'bar', ''), ('bar', 'foo', ''), ('bar', 'bar', '')]],
|
||||
[[('foo', 'bar', ''), ('bar', 'foo', '')]]),
|
||||
|
||||
('foo', [0, 1, 2],
|
||||
[[('foo', '', ''), ('bar', '')]],
|
||||
[[('foo', '', '')]]),
|
||||
])
|
||||
def test_set_pattern(pattern, filter_cols, before, after):
|
||||
@hypothesis.given(strategies.text())
|
||||
def test_set_pattern(pat):
|
||||
"""Validate the filtering and sorting results of set_pattern."""
|
||||
# TODO: just test that it calls the mock on its child categories
|
||||
model = _create_model(before, filter_cols)
|
||||
model.set_pattern(pattern)
|
||||
actual = _extract_model_data(model)
|
||||
assert actual == after
|
||||
cols = [1, 2, 3]
|
||||
model = completionmodel.CompletionModel(columns_to_filter=cols)
|
||||
cats = [mock.Mock(spec=['set_pattern'])] * 3
|
||||
for c in cats:
|
||||
c.set_pattern = mock.Mock()
|
||||
model.add_category(c)
|
||||
model.set_pattern(pat)
|
||||
assert all(c.set_pattern.called_with([pat, cols]) for c in cats)
|
||||
|
@ -19,13 +19,13 @@
|
||||
|
||||
"""Tests for the CompletionView Object."""
|
||||
|
||||
import unittest.mock
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from PyQt5.QtGui import QStandardItem, QColor
|
||||
|
||||
from qutebrowser.completion import completionwidget
|
||||
from qutebrowser.completion.models import completionmodel
|
||||
from qutebrowser.completion.models import completionmodel, listcategory
|
||||
from qutebrowser.commands import cmdexc
|
||||
|
||||
|
||||
@ -70,19 +70,11 @@ def completionview(qtbot, status_command_stub, config_stub, win_registry,
|
||||
return view
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simplemodel(completionview):
|
||||
"""A completion model with one item."""
|
||||
model = completionmodel.CompletionModel()
|
||||
model.add_list('', [('foo'),])
|
||||
return model
|
||||
|
||||
|
||||
def test_set_model(completionview):
|
||||
"""Ensure set_model actually sets the model and expands all categories."""
|
||||
model = completionmodel.CompletionModel()
|
||||
for i in range(3):
|
||||
model.add_list(str(i), [('foo',)])
|
||||
cat = listcategory.ListCategory('', [('foo',)])
|
||||
completionview.set_model(model)
|
||||
assert completionview.model() is model
|
||||
for i in range(model.rowCount()):
|
||||
@ -91,7 +83,7 @@ def test_set_model(completionview):
|
||||
|
||||
def test_set_pattern(completionview):
|
||||
model = completionmodel.CompletionModel()
|
||||
model.set_pattern = unittest.mock.Mock()
|
||||
model.set_pattern = mock.Mock()
|
||||
completionview.set_model(model, 'foo')
|
||||
model.set_pattern.assert_called_with('foo')
|
||||
|
||||
@ -158,7 +150,8 @@ def test_completion_item_focus(which, tree, expected, completionview, qtbot):
|
||||
"""
|
||||
model = completionmodel.CompletionModel()
|
||||
for catdata in tree:
|
||||
model.add_list('', (x,) for x in catdata)
|
||||
cat = listcategory.ListCategory('', ((x,) for x in catdata))
|
||||
model.add_category(cat)
|
||||
completionview.set_model(model)
|
||||
for entry in expected:
|
||||
if entry is None:
|
||||
@ -203,7 +196,8 @@ def test_completion_show(show, rows, quick_complete, completionview,
|
||||
|
||||
model = completionmodel.CompletionModel()
|
||||
for name in rows:
|
||||
model.add_list('', [(name,)])
|
||||
cat = listcategory.ListCategory('', [(name,)])
|
||||
model.add_category(cat)
|
||||
|
||||
assert not completionview.isVisible()
|
||||
completionview.set_model(model)
|
||||
@ -217,27 +211,33 @@ def test_completion_show(show, rows, quick_complete, completionview,
|
||||
assert not completionview.isVisible()
|
||||
|
||||
|
||||
def test_completion_item_del(completionview, simplemodel):
|
||||
def test_completion_item_del(completionview):
|
||||
"""Test that completion_item_del invokes delete_cur_item in the model."""
|
||||
simplemodel.srcmodel.delete_cur_item = unittest.mock.Mock()
|
||||
completionview.set_model(simplemodel)
|
||||
func = mock.Mock()
|
||||
model = completionmodel.CompletionModel(delete_cur_item=func)
|
||||
model.add_category(listcategory.ListCategory('', [('foo',)]))
|
||||
completionview.set_model(model)
|
||||
completionview.completion_item_focus('next')
|
||||
completionview.completion_item_del()
|
||||
assert simplemodel.srcmodel.delete_cur_item.called
|
||||
assert func.called
|
||||
|
||||
|
||||
def test_completion_item_del_no_selection(completionview, simplemodel):
|
||||
def test_completion_item_del_no_selection(completionview):
|
||||
"""Test that completion_item_del with no selected index."""
|
||||
simplemodel.srcmodel.delete_cur_item = unittest.mock.Mock()
|
||||
completionview.set_model(simplemodel)
|
||||
func = mock.Mock()
|
||||
model = completionmodel.CompletionModel(delete_cur_item=func)
|
||||
model.add_category(listcategory.ListCategory('', [('foo',)]))
|
||||
completionview.set_model(model)
|
||||
with pytest.raises(cmdexc.CommandError):
|
||||
completionview.completion_item_del()
|
||||
assert not simplemodel.srcmodel.delete_cur_item.called
|
||||
assert not func.called
|
||||
|
||||
|
||||
def test_completion_item_del_no_func(completionview, simplemodel):
|
||||
def test_completion_item_del_no_func(completionview):
|
||||
"""Test completion_item_del with no delete_cur_item in the model."""
|
||||
completionview.set_model(simplemodel)
|
||||
model = completionmodel.CompletionModel()
|
||||
model.add_category(listcategory.ListCategory('', [('foo',)]))
|
||||
completionview.set_model(model)
|
||||
completionview.completion_item_focus('next')
|
||||
with pytest.raises(cmdexc.CommandError):
|
||||
completionview.completion_item_del()
|
||||
|
70
tests/unit/completion/test_listcategory.py
Normal file
70
tests/unit/completion/test_listcategory.py
Normal file
@ -0,0 +1,70 @@
|
||||
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
|
||||
|
||||
# Copyright 2015-2016 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 CompletionFilterModel."""
|
||||
|
||||
import pytest
|
||||
|
||||
from qutebrowser.completion.models import listcategory
|
||||
|
||||
|
||||
def _validate(cat, expected):
|
||||
"""Check that a category contains the expected items in the given order.
|
||||
|
||||
Args:
|
||||
cat: The category to inspect.
|
||||
expected: A list of tuples containing the expected items.
|
||||
"""
|
||||
assert cat.rowCount() == len(expected)
|
||||
for row, items in enumerate(expected):
|
||||
for col, item in enumerate(items):
|
||||
assert cat.data(cat.index(row, col)) == item
|
||||
|
||||
|
||||
@pytest.mark.parametrize('pattern, filter_cols, before, after', [
|
||||
('foo', [0],
|
||||
[('foo', '', ''), ('bar', '', '')],
|
||||
[('foo', '', '')]),
|
||||
|
||||
('foo', [0],
|
||||
[('foob', '', ''), ('fooc', '', ''), ('fooa', '', '')],
|
||||
[('fooa', '', ''), ('foob', '', ''), ('fooc', '', '')]),
|
||||
|
||||
# prefer foobar as it starts with the pattern
|
||||
('foo', [0],
|
||||
[('barfoo', '', ''), ('foobar', '', '')],
|
||||
[('foobar', '', ''), ('barfoo', '', '')]),
|
||||
|
||||
('foo', [1],
|
||||
[('foo', 'bar', ''), ('bar', 'foo', '')],
|
||||
[('bar', 'foo', '')]),
|
||||
|
||||
('foo', [0, 1],
|
||||
[('foo', 'bar', ''), ('bar', 'foo', ''), ('bar', 'bar', '')],
|
||||
[('foo', 'bar', ''), ('bar', 'foo', '')]),
|
||||
|
||||
('foo', [0, 1, 2],
|
||||
[('foo', '', ''), ('bar', '')],
|
||||
[('foo', '', '')]),
|
||||
])
|
||||
def test_set_pattern(pattern, filter_cols, before, after):
|
||||
"""Validate the filtering and sorting results of set_pattern."""
|
||||
cat = listcategory.ListCategory('Foo', before)
|
||||
cat.set_pattern(pattern, filter_cols)
|
||||
_validate(cat, after)
|
@ -134,27 +134,25 @@ def _mock_view_index(model, category_num, child_num, qtbot):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def quickmarks(init_sql):
|
||||
"""Pre-populate the quickmark database."""
|
||||
table = sql.SqlTable('Quickmarks', ['name', 'url'], primary_key='name')
|
||||
table.insert(['aw', 'https://wiki.archlinux.org'])
|
||||
table.insert(['ddg', 'https://duckduckgo.com'])
|
||||
table.insert(['wiki', 'https://wikipedia.org'])
|
||||
objreg.register('quickmark-manager', table)
|
||||
yield table
|
||||
objreg.delete('quickmark-manager')
|
||||
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(init_sql):
|
||||
"""Pre-populate the bookmark database."""
|
||||
table = sql.SqlTable('Bookmarks', ['url', 'title'], primary_key='url')
|
||||
table.insert(['https://github.com', 'GitHub'])
|
||||
table.insert(['https://python.org', 'Welcome to Python.org'])
|
||||
table.insert(['http://qutebrowser.org', 'qutebrowser | qutebrowser'])
|
||||
objreg.register('bookmark-manager', table)
|
||||
yield table
|
||||
objreg.delete('bookmark-manager')
|
||||
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
|
||||
@ -315,9 +313,9 @@ def test_url_completion_delete_bookmark(qtmodeltester, config_stub,
|
||||
# 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
|
||||
assert 'https://python.org' in bookmarks
|
||||
assert 'http://qutebrowser.org' in bookmarks
|
||||
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(qtmodeltester, config_stub,
|
||||
@ -332,9 +330,9 @@ def test_url_completion_delete_quickmark(qtmodeltester, config_stub,
|
||||
# delete item (0, 1) -> (quickmarks, 'ddg' )
|
||||
view = _mock_view_index(model, 0, 1, qtbot)
|
||||
model.delete_cur_item(view)
|
||||
assert 'aw' in quickmarks
|
||||
assert 'ddg' not in quickmarks
|
||||
assert 'wiki' in quickmarks
|
||||
assert 'aw' in quickmarks.marks
|
||||
assert 'ddg' not in quickmarks.marks
|
||||
assert 'wiki' in quickmarks.marks
|
||||
|
||||
|
||||
def test_session_completion(qtmodeltester, session_manager_stub):
|
||||
|
@ -1,146 +0,0 @@
|
||||
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
|
||||
|
||||
# Copyright 2015-2017 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 CompletionFilterModel."""
|
||||
|
||||
import pytest
|
||||
|
||||
from qutebrowser.completion.models import listcategory, sortfilter
|
||||
|
||||
|
||||
# TODO: merge listcategory and sortfilter
|
||||
def _create_model(data):
|
||||
"""Create a completion model populated with the given data.
|
||||
|
||||
data: A list of lists, where each sub-list represents a category, each
|
||||
tuple in the sub-list represents an item, and each value in the
|
||||
tuple represents the item data for that column
|
||||
"""
|
||||
model = completionmodel.CompletionModel()
|
||||
for catdata in data:
|
||||
cat = model.add_list(itemdata)
|
||||
return model
|
||||
|
||||
|
||||
def _extract_model_data(model):
|
||||
"""Express a model's data as a list for easier comparison.
|
||||
|
||||
Return: A list of lists, where each sub-list represents a category, each
|
||||
tuple in the sub-list represents an item, and each value in the
|
||||
tuple represents the item data for that column
|
||||
"""
|
||||
data = []
|
||||
for i in range(0, model.rowCount()):
|
||||
cat_idx = model.index(i, 0)
|
||||
row = []
|
||||
for j in range(0, model.rowCount(cat_idx)):
|
||||
row.append((model.data(cat_idx.child(j, 0)),
|
||||
model.data(cat_idx.child(j, 1)),
|
||||
model.data(cat_idx.child(j, 2))))
|
||||
data.append(row)
|
||||
return data
|
||||
|
||||
|
||||
@pytest.mark.parametrize('pattern, data, expected', [
|
||||
('foo', 'barfoobar', True),
|
||||
('foo bar', 'barfoobar', True),
|
||||
('foo bar', 'barfoobar', True),
|
||||
('foo bar', 'barfoobazbar', True),
|
||||
('foo bar', 'barfoobazbar', True),
|
||||
('foo', 'barFOObar', True),
|
||||
('Foo', 'barfOObar', True),
|
||||
('ab', 'aonebtwo', False),
|
||||
('33', 'l33t', True),
|
||||
('x', 'blah', False),
|
||||
('4', 'blah', False),
|
||||
])
|
||||
def test_filter_accepts_row(pattern, data, expected):
|
||||
source_model = completionmodel.CompletionModel()
|
||||
cat = source_model.new_category('test')
|
||||
source_model.new_item(cat, data)
|
||||
|
||||
filter_model = sortfilter.CompletionFilterModel(source_model)
|
||||
filter_model.set_pattern(pattern)
|
||||
assert filter_model.rowCount() == 1 # "test" category
|
||||
idx = filter_model.index(0, 0)
|
||||
assert idx.isValid()
|
||||
|
||||
row_count = filter_model.rowCount(idx)
|
||||
assert row_count == (1 if expected else 0)
|
||||
|
||||
|
||||
def test_set_source_model():
|
||||
"""Ensure setSourceModel sets source_model and clears the pattern."""
|
||||
model1 = base.CompletionModel()
|
||||
model2 = base.CompletionModel()
|
||||
filter_model = sortfilter.CompletionFilterModel(model1)
|
||||
filter_model.set_pattern('foo')
|
||||
# sourceModel() is cached as srcmodel, so make sure both match
|
||||
assert filter_model.srcmodel is model1
|
||||
assert filter_model.sourceModel() is model1
|
||||
assert filter_model.pattern == 'foo'
|
||||
filter_model.setSourceModel(model2)
|
||||
assert filter_model.srcmodel is model2
|
||||
assert filter_model.sourceModel() is model2
|
||||
assert not filter_model.pattern
|
||||
|
||||
|
||||
@pytest.mark.parametrize('pattern, filter_cols, before, after', [
|
||||
('foo', [0],
|
||||
[[('foo', '', ''), ('bar', '', '')]],
|
||||
[[('foo', '', '')]]),
|
||||
|
||||
('foo', [0],
|
||||
[[('foob', '', ''), ('fooc', '', ''), ('fooa', '', '')]],
|
||||
[[('fooa', '', ''), ('foob', '', ''), ('fooc', '', '')]]),
|
||||
|
||||
('foo', [0],
|
||||
[[('foo', '', '')], [('bar', '', '')]],
|
||||
[[('foo', '', '')], []]),
|
||||
|
||||
# prefer foobar as it starts with the pattern
|
||||
('foo', [0],
|
||||
[[('barfoo', '', ''), ('foobar', '', '')]],
|
||||
[[('foobar', '', ''), ('barfoo', '', '')]]),
|
||||
|
||||
# however, don't rearrange categories
|
||||
('foo', [0],
|
||||
[[('barfoo', '', '')], [('foobar', '', '')]],
|
||||
[[('barfoo', '', '')], [('foobar', '', '')]]),
|
||||
|
||||
('foo', [1],
|
||||
[[('foo', 'bar', ''), ('bar', 'foo', '')]],
|
||||
[[('bar', 'foo', '')]]),
|
||||
|
||||
('foo', [0, 1],
|
||||
[[('foo', 'bar', ''), ('bar', 'foo', ''), ('bar', 'bar', '')]],
|
||||
[[('foo', 'bar', ''), ('bar', 'foo', '')]]),
|
||||
|
||||
('foo', [0, 1, 2],
|
||||
[[('foo', '', ''), ('bar', '')]],
|
||||
[[('foo', '', '')]]),
|
||||
])
|
||||
def test_set_pattern(pattern, filter_cols, before, after):
|
||||
"""Validate the filtering and sorting results of set_pattern."""
|
||||
model = _create_model(before)
|
||||
model.columns_to_filter = filter_cols
|
||||
filter_model = sortfilter.CompletionFilterModel(model)
|
||||
filter_model.set_pattern(pattern)
|
||||
actual = _extract_model_data(filter_model)
|
||||
assert actual == after
|
156
tests/unit/completion/test_sqlcateogry.py
Normal file
156
tests/unit/completion/test_sqlcateogry.py
Normal file
@ -0,0 +1,156 @@
|
||||
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
|
||||
|
||||
# Copyright 2016-2017 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/>.
|
||||
|
||||
"""Test SQL-based completions."""
|
||||
|
||||
import pytest
|
||||
|
||||
from qutebrowser.misc import sql
|
||||
from qutebrowser.completion.models import sqlcategory
|
||||
|
||||
|
||||
pytestmark = pytest.mark.usefixtures('init_sql')
|
||||
|
||||
|
||||
def _validate(cat, expected):
|
||||
"""Check that a category contains the expected items in the given order.
|
||||
|
||||
Args:
|
||||
cat: The category to inspect.
|
||||
expected: A list of tuples containing the expected items.
|
||||
"""
|
||||
assert cat.rowCount() == len(expected)
|
||||
for row, items in enumerate(expected):
|
||||
for col, item in enumerate(items):
|
||||
assert cat.data(cat.index(row, col)) == item
|
||||
|
||||
|
||||
@pytest.mark.parametrize('sort_by, sort_order, data, expected', [
|
||||
(None, 'asc',
|
||||
[('B', 'C', 'D'), ('A', 'F', 'C'), ('C', 'A', 'G')],
|
||||
[('B', 'C', 'D'), ('A', 'F', 'C'), ('C', 'A', 'G')]),
|
||||
|
||||
('a', 'asc',
|
||||
[('B', 'C', 'D'), ('A', 'F', 'C'), ('C', 'A', 'G')],
|
||||
[('A', 'F', 'C'), ('B', 'C', 'D'), ('C', 'A', 'G')]),
|
||||
|
||||
('a', 'desc',
|
||||
[('B', 'C', 'D'), ('A', 'F', 'C'), ('C', 'A', 'G')],
|
||||
[('C', 'A', 'G'), ('B', 'C', 'D'), ('A', 'F', 'C')]),
|
||||
|
||||
('b', 'asc',
|
||||
[('B', 'C', 'D'), ('A', 'F', 'C'), ('C', 'A', 'G')],
|
||||
[('C', 'A', 'G'), ('B', 'C', 'D'), ('A', 'F', 'C')]),
|
||||
|
||||
('b', 'desc',
|
||||
[('B', 'C', 'D'), ('A', 'F', 'C'), ('C', 'A', 'G')],
|
||||
[('A', 'F', 'C'), ('B', 'C', 'D'), ('C', 'A', 'G')]),
|
||||
|
||||
('c', 'asc',
|
||||
[('B', 'C', 2), ('A', 'F', 0), ('C', 'A', 1)],
|
||||
[('A', 'F', 0), ('C', 'A', 1), ('B', 'C', 2)]),
|
||||
|
||||
('c', 'desc',
|
||||
[('B', 'C', 2), ('A', 'F', 0), ('C', 'A', 1)],
|
||||
[('B', 'C', 2), ('C', 'A', 1), ('A', 'F', 0)]),
|
||||
])
|
||||
def test_sorting(sort_by, sort_order, data, expected):
|
||||
table = sql.SqlTable('Foo', ['a', 'b', 'c'], primary_key='a')
|
||||
for row in data:
|
||||
table.insert(row)
|
||||
cat = sqlcategory.SqlCategory('Foo', sort_by=sort_by,
|
||||
sort_order=sort_order)
|
||||
_validate(cat, expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('pattern, filter_cols, before, after', [
|
||||
('foo', [0],
|
||||
[('foo', '', ''), ('bar', '', ''), ('aafobbb', '', '')],
|
||||
[('foo',)]),
|
||||
|
||||
('foo', [0],
|
||||
[('baz', 'bar', 'foo'), ('foo', '', ''), ('bar', 'foo', '')],
|
||||
[('foo', '', '')]),
|
||||
|
||||
('foo', [0],
|
||||
[('fooa', '', ''), ('foob', '', ''), ('fooc', '', '')],
|
||||
[('fooa', '', ''), ('foob', '', ''), ('fooc', '', '')]),
|
||||
|
||||
('foo', [1],
|
||||
[('foo', 'bar', ''), ('bar', 'foo', '')],
|
||||
[('bar', 'foo', '')]),
|
||||
|
||||
('foo', [0, 1],
|
||||
[('foo', 'bar', ''), ('bar', 'foo', ''), ('biz', 'baz', 'foo')],
|
||||
[('foo', 'bar', ''), ('bar', 'foo', '')]),
|
||||
|
||||
('foo', [0, 1, 2],
|
||||
[('foo', '', ''), ('bar', '', ''), ('baz', 'bar', 'foo')],
|
||||
[('foo', '', ''), ('baz', 'bar', 'foo')]),
|
||||
|
||||
('foo bar', [0],
|
||||
[('foo', '', ''), ('bar foo', '', ''), ('xfooyybarz', '', '')],
|
||||
[('xfooyybarz', '', '')]),
|
||||
|
||||
('foo%bar', [0],
|
||||
[('foo%bar', '', ''), ('foo bar', '', ''), ('foobar', '', '')],
|
||||
[('foo%bar', '', '')]),
|
||||
|
||||
('_', [0],
|
||||
[('a_b', '', ''), ('__a', '', ''), ('abc', '', '')],
|
||||
[('a_b', '', ''), ('__a', '', '')]),
|
||||
|
||||
('%', [0, 1],
|
||||
[('\\foo', '\\bar', '')],
|
||||
[]),
|
||||
|
||||
("can't", [0],
|
||||
[("can't touch this", '', ''), ('a', '', '')],
|
||||
[("can't touch this", '', '')]),
|
||||
])
|
||||
def test_set_pattern(pattern, filter_cols, before, after):
|
||||
"""Validate the filtering and sorting results of set_pattern."""
|
||||
table = sql.SqlTable('Foo', ['a', 'b', 'c'], primary_key='a')
|
||||
for row in before:
|
||||
table.insert(row)
|
||||
cat = sqlcategory.SqlCategory('Foo')
|
||||
cat.set_pattern(pattern, filter_cols)
|
||||
_validate(cat, after)
|
||||
|
||||
|
||||
def test_select():
|
||||
table = sql.SqlTable('Foo', ['a', 'b', 'c'], primary_key='a')
|
||||
table.insert(['foo', 'bar', 'baz'])
|
||||
cat = sqlcategory.SqlCategory('Foo', select='b, c, a')
|
||||
_validate(cat, [('bar', 'baz', 'foo')])
|
||||
|
||||
|
||||
def test_where():
|
||||
table = sql.SqlTable('Foo', ['a', 'b', 'c'], primary_key='a')
|
||||
table.insert(['foo', 'bar', False])
|
||||
table.insert(['baz', 'biz', True])
|
||||
cat = sqlcategory.SqlCategory('Foo', where='not c')
|
||||
_validate(cat, [('foo', 'bar', False)])
|
||||
|
||||
|
||||
def test_entry():
|
||||
table = sql.SqlTable('Foo', ['a', 'b', 'c'], primary_key='a')
|
||||
assert hasattr(table.Entry, 'a')
|
||||
assert hasattr(table.Entry, 'b')
|
||||
assert hasattr(table.Entry, 'c')
|
@ -1,230 +0,0 @@
|
||||
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
|
||||
|
||||
# Copyright 2016-2017 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 base sql completion model."""
|
||||
|
||||
import pytest
|
||||
|
||||
from qutebrowser.misc import sql
|
||||
from qutebrowser.completion.models import sqlmodel
|
||||
|
||||
|
||||
pytestmark = pytest.mark.usefixtures('init_sql')
|
||||
|
||||
|
||||
def _check_model(model, expected):
|
||||
"""Check that a model contains the expected items in the given order.
|
||||
|
||||
Args:
|
||||
expected: A list of form
|
||||
[
|
||||
(cat, [(name, desc, misc), (name, desc, misc), ...]),
|
||||
(cat, [(name, desc, misc), (name, desc, misc), ...]),
|
||||
...
|
||||
]
|
||||
"""
|
||||
assert model.rowCount() == len(expected)
|
||||
for i, (expected_title, expected_items) in enumerate(expected):
|
||||
catidx = model.index(i, 0)
|
||||
assert model.data(catidx) == expected_title
|
||||
assert model.rowCount(catidx) == len(expected_items)
|
||||
for j, (name, desc, misc) in enumerate(expected_items):
|
||||
assert model.data(model.index(j, 0, catidx)) == name
|
||||
assert model.data(model.index(j, 1, catidx)) == desc
|
||||
assert model.data(model.index(j, 2, catidx)) == misc
|
||||
|
||||
|
||||
@pytest.mark.parametrize('rowcounts, expected', [
|
||||
([0], 0),
|
||||
([1], 1),
|
||||
([2], 2),
|
||||
([0, 0], 0),
|
||||
([0, 0, 0], 0),
|
||||
([1, 1], 2),
|
||||
([3, 2, 1], 6),
|
||||
([0, 2, 0], 2),
|
||||
])
|
||||
def test_count(rowcounts, expected):
|
||||
model = sqlmodel.SqlCompletionModel()
|
||||
for i, rowcount in enumerate(rowcounts):
|
||||
name = 'Foo' + str(i)
|
||||
table = sql.SqlTable(name, ['a'], primary_key='a')
|
||||
for rownum in range(rowcount):
|
||||
table.insert([rownum])
|
||||
model.new_category(name)
|
||||
assert model.count() == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize('sort_by, sort_order, data, expected', [
|
||||
(None, 'asc',
|
||||
[('B', 'C', 'D'), ('A', 'F', 'C'), ('C', 'A', 'G')],
|
||||
[('B', 'C', 'D'), ('A', 'F', 'C'), ('C', 'A', 'G')]),
|
||||
|
||||
('a', 'asc',
|
||||
[('B', 'C', 'D'), ('A', 'F', 'C'), ('C', 'A', 'G')],
|
||||
[('A', 'F', 'C'), ('B', 'C', 'D'), ('C', 'A', 'G')]),
|
||||
|
||||
('a', 'desc',
|
||||
[('B', 'C', 'D'), ('A', 'F', 'C'), ('C', 'A', 'G')],
|
||||
[('C', 'A', 'G'), ('B', 'C', 'D'), ('A', 'F', 'C')]),
|
||||
|
||||
('b', 'asc',
|
||||
[('B', 'C', 'D'), ('A', 'F', 'C'), ('C', 'A', 'G')],
|
||||
[('C', 'A', 'G'), ('B', 'C', 'D'), ('A', 'F', 'C')]),
|
||||
|
||||
('b', 'desc',
|
||||
[('B', 'C', 'D'), ('A', 'F', 'C'), ('C', 'A', 'G')],
|
||||
[('A', 'F', 'C'), ('B', 'C', 'D'), ('C', 'A', 'G')]),
|
||||
|
||||
('c', 'asc',
|
||||
[('B', 'C', 2), ('A', 'F', 0), ('C', 'A', 1)],
|
||||
[('A', 'F', 0), ('C', 'A', 1), ('B', 'C', 2)]),
|
||||
|
||||
('c', 'desc',
|
||||
[('B', 'C', 2), ('A', 'F', 0), ('C', 'A', 1)],
|
||||
[('B', 'C', 2), ('C', 'A', 1), ('A', 'F', 0)]),
|
||||
])
|
||||
def test_sorting(sort_by, sort_order, data, expected):
|
||||
table = sql.SqlTable('Foo', ['a', 'b', 'c'], primary_key='a')
|
||||
for row in data:
|
||||
table.insert(row)
|
||||
model = sqlmodel.SqlCompletionModel()
|
||||
model.new_category('Foo', sort_by=sort_by, sort_order=sort_order)
|
||||
_check_model(model, [('Foo', expected)])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('pattern, filter_cols, before, after', [
|
||||
('foo', [0],
|
||||
[('A', [('foo', '', ''), ('bar', '', ''), ('aafobbb', '', '')])],
|
||||
[('A', [('foo', '', '')])]),
|
||||
|
||||
('foo', [0],
|
||||
[('A', [('baz', 'bar', 'foo'), ('foo', '', ''), ('bar', 'foo', '')])],
|
||||
[('A', [('foo', '', '')])]),
|
||||
|
||||
('foo', [0],
|
||||
[('A', [('foo', '', ''), ('bar', '', '')]),
|
||||
('B', [('foo', '', ''), ('bar', '', '')])],
|
||||
[('A', [('foo', '', '')]), ('B', [('foo', '', '')])]),
|
||||
|
||||
('foo', [0],
|
||||
[('A', [('fooa', '', ''), ('foob', '', ''), ('fooc', '', '')])],
|
||||
[('A', [('fooa', '', ''), ('foob', '', ''), ('fooc', '', '')])]),
|
||||
|
||||
('foo', [0],
|
||||
[('A', [('foo', '', '')]), ('B', [('bar', '', '')])],
|
||||
[('A', [('foo', '', '')]), ('B', [])]),
|
||||
|
||||
('foo', [1],
|
||||
[('A', [('foo', 'bar', ''), ('bar', 'foo', '')])],
|
||||
[('A', [('bar', 'foo', '')])]),
|
||||
|
||||
('foo', [0, 1],
|
||||
[('A', [('foo', 'bar', ''), ('bar', 'foo', '')])],
|
||||
[('A', [('foo', 'bar', ''), ('bar', 'foo', '')])]),
|
||||
|
||||
('foo', [0, 1, 2],
|
||||
[('A', [('foo', '', ''), ('bar', '', '')])],
|
||||
[('A', [('foo', '', '')])]),
|
||||
|
||||
('foo bar', [0],
|
||||
[('A', [('foo', '', ''), ('bar foo', '', ''), ('xfooyybarz', '', '')])],
|
||||
[('A', [('xfooyybarz', '', '')])]),
|
||||
|
||||
('foo%bar', [0],
|
||||
[('A', [('foo%bar', '', ''), ('foo bar', '', ''), ('foobar', '', '')])],
|
||||
[('A', [('foo%bar', '', '')])]),
|
||||
|
||||
('_', [0],
|
||||
[('A', [('a_b', '', ''), ('__a', '', ''), ('abc', '', '')])],
|
||||
[('A', [('a_b', '', ''), ('__a', '', '')])]),
|
||||
|
||||
('%', [0, 1],
|
||||
[('A', [('\\foo', '\\bar', '')])],
|
||||
[('A', [])]),
|
||||
|
||||
("can't", [0],
|
||||
[('A', [("can't touch this", '', ''), ('a', '', '')])],
|
||||
[('A', [("can't touch this", '', '')])]),
|
||||
])
|
||||
def test_set_pattern(pattern, filter_cols, before, after):
|
||||
"""Validate the filtering and sorting results of set_pattern."""
|
||||
model = sqlmodel.SqlCompletionModel(columns_to_filter=filter_cols)
|
||||
for name, rows in before:
|
||||
table = sql.SqlTable(name, ['a', 'b', 'c'], primary_key='a')
|
||||
for row in rows:
|
||||
table.insert(row)
|
||||
model.new_category(name)
|
||||
model.set_pattern(pattern)
|
||||
_check_model(model, after)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('data, first, last', [
|
||||
([('A', ['Aa'])], 'Aa', 'Aa'),
|
||||
([('A', ['Aa', 'Ba'])], 'Aa', 'Ba'),
|
||||
([('A', ['Aa', 'Ab', 'Ac']), ('B', ['Ba', 'Bb']),
|
||||
('C', ['Ca'])], 'Aa', 'Ca'),
|
||||
([('A', []), ('B', ['Ba'])], 'Ba', 'Ba'),
|
||||
([('A', []), ('B', []), ('C', ['Ca'])], 'Ca', 'Ca'),
|
||||
([('A', []), ('B', []), ('C', ['Ca', 'Cb'])], 'Ca', 'Cb'),
|
||||
([('A', ['Aa']), ('B', [])], 'Aa', 'Aa'),
|
||||
([('A', ['Aa']), ('B', []), ('C', [])], 'Aa', 'Aa'),
|
||||
([('A', ['Aa']), ('B', []), ('C', ['Ca'])], 'Aa', 'Ca'),
|
||||
([('A', []), ('B', [])], None, None),
|
||||
])
|
||||
def test_first_last_item(data, first, last):
|
||||
"""Test that first() and last() return indexes to the first and last items.
|
||||
|
||||
Args:
|
||||
data: Input to _make_model
|
||||
first: text of the first item
|
||||
last: text of the last item
|
||||
"""
|
||||
model = sqlmodel.SqlCompletionModel()
|
||||
for name, rows in data:
|
||||
table = sql.SqlTable(name, ['a'], primary_key='a')
|
||||
for row in rows:
|
||||
table.insert([row])
|
||||
model.new_category(name)
|
||||
assert model.data(model.first_item()) == first
|
||||
assert model.data(model.last_item()) == last
|
||||
|
||||
|
||||
def test_select():
|
||||
table = sql.SqlTable('test_select', ['a', 'b', 'c'], primary_key='a')
|
||||
table.insert(['foo', 'bar', 'baz'])
|
||||
model = sqlmodel.SqlCompletionModel()
|
||||
model.new_category('test_select', select='b, c, a')
|
||||
_check_model(model, [('test_select', [('bar', 'baz', 'foo')])])
|
||||
|
||||
|
||||
def test_where():
|
||||
table = sql.SqlTable('test_where', ['a', 'b', 'c'], primary_key='a')
|
||||
table.insert(['foo', 'bar', False])
|
||||
table.insert(['baz', 'biz', True])
|
||||
model = sqlmodel.SqlCompletionModel()
|
||||
model.new_category('test_where', where='not c')
|
||||
_check_model(model, [('test_where', [('foo', 'bar', False)])])
|
||||
|
||||
|
||||
def test_entry():
|
||||
table = sql.SqlTable('test_entry', ['a', 'b', 'c'], primary_key='a')
|
||||
assert hasattr(table.Entry, 'a')
|
||||
assert hasattr(table.Entry, 'b')
|
||||
assert hasattr(table.Entry, 'c')
|
Loading…
Reference in New Issue
Block a user