mirror of
https://github.com/python/cpython.git
synced 2025-12-04 16:43:27 +00:00
Merge from 3.3 #18489 Search Engine tests
This commit is contained in:
commit
2e87c87933
3 changed files with 369 additions and 34 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
'''Define SearchEngine for search dialogs.'''
|
'''Define SearchEngine for search dialogs.'''
|
||||||
import re
|
import re
|
||||||
from tkinter import *
|
from tkinter import StringVar, BooleanVar, TclError
|
||||||
import tkinter.messagebox as tkMessageBox
|
import tkinter.messagebox as tkMessageBox
|
||||||
|
|
||||||
def get(root):
|
def get(root):
|
||||||
|
|
@ -22,14 +22,13 @@ class SearchEngine:
|
||||||
|
|
||||||
The dialogs bind these to the UI elements present in the dialogs.
|
The dialogs bind these to the UI elements present in the dialogs.
|
||||||
'''
|
'''
|
||||||
self.root = root
|
self.root = root # need for report_error()
|
||||||
self.patvar = StringVar(root) # search pattern
|
self.patvar = StringVar(root, '') # search pattern
|
||||||
self.revar = BooleanVar(root) # regular expression?
|
self.revar = BooleanVar(root, False) # regular expression?
|
||||||
self.casevar = BooleanVar(root) # match case?
|
self.casevar = BooleanVar(root, False) # match case?
|
||||||
self.wordvar = BooleanVar(root) # match whole word?
|
self.wordvar = BooleanVar(root, False) # match whole word?
|
||||||
self.wrapvar = BooleanVar(root) # wrap around buffer?
|
self.wrapvar = BooleanVar(root, True) # wrap around buffer?
|
||||||
self.wrapvar.set(1) # (on by default)
|
self.backvar = BooleanVar(root, False) # search backwards?
|
||||||
self.backvar = BooleanVar(root) # search backwards?
|
|
||||||
|
|
||||||
# Access methods
|
# Access methods
|
||||||
|
|
||||||
|
|
@ -56,9 +55,16 @@ class SearchEngine:
|
||||||
|
|
||||||
# Higher level access methods
|
# Higher level access methods
|
||||||
|
|
||||||
|
def setcookedpat(self, pat):
|
||||||
|
"Set pattern after escaping if re."
|
||||||
|
# called only in SearchDialog.py: 66
|
||||||
|
if self.isre():
|
||||||
|
pat = re.escape(pat)
|
||||||
|
self.setpat(pat)
|
||||||
|
|
||||||
def getcookedpat(self):
|
def getcookedpat(self):
|
||||||
pat = self.getpat()
|
pat = self.getpat()
|
||||||
if not self.isre():
|
if not self.isre(): # if True, see setcookedpat
|
||||||
pat = re.escape(pat)
|
pat = re.escape(pat)
|
||||||
if self.isword():
|
if self.isword():
|
||||||
pat = r"\b%s\b" % pat
|
pat = r"\b%s\b" % pat
|
||||||
|
|
@ -90,33 +96,28 @@ class SearchEngine:
|
||||||
# Derived class could override this with something fancier
|
# Derived class could override this with something fancier
|
||||||
msg = "Error: " + str(msg)
|
msg = "Error: " + str(msg)
|
||||||
if pat:
|
if pat:
|
||||||
msg = msg + "\np\Pattern: " + str(pat)
|
msg = msg + "\nPattern: " + str(pat)
|
||||||
if col >= 0:
|
if col >= 0:
|
||||||
msg = msg + "\nOffset: " + str(col)
|
msg = msg + "\nOffset: " + str(col)
|
||||||
tkMessageBox.showerror("Regular expression error",
|
tkMessageBox.showerror("Regular expression error",
|
||||||
msg, master=self.root)
|
msg, master=self.root)
|
||||||
|
|
||||||
def setcookedpat(self, pat):
|
|
||||||
if self.isre():
|
|
||||||
pat = re.escape(pat)
|
|
||||||
self.setpat(pat)
|
|
||||||
|
|
||||||
def search_text(self, text, prog=None, ok=0):
|
def search_text(self, text, prog=None, ok=0):
|
||||||
'''Return (lineno, matchobj) for prog in text widget, or None.
|
'''Return (lineno, matchobj) or None for forward/backward search.
|
||||||
|
|
||||||
If prog is given, it should be a precompiled pattern.
|
This function calls the right function with the right arguments.
|
||||||
Wrap (yes/no) and direction (forward/back) settings are used.
|
It directly return the result of that call.
|
||||||
|
|
||||||
The search starts at the selection (if there is one) or at the
|
Text is a text widget. Prog is a precompiled pattern.
|
||||||
insert mark (otherwise). If the search is forward, it starts
|
The ok parameteris a bit complicated as it has two effects.
|
||||||
at the right of the selection; for a backward search, it
|
|
||||||
starts at the left end. An empty match exactly at either end
|
|
||||||
of the selection (or at the insert mark if there is no
|
|
||||||
selection) is ignored unless the ok flag is true -- this is
|
|
||||||
done to guarantee progress.
|
|
||||||
|
|
||||||
If the search is allowed to wrap around, it will return the
|
If there is a selection, the search begin at either end,
|
||||||
original selection if (and only if) it is the only match.
|
depending on the direction setting and ok, with ok meaning that
|
||||||
|
the search starts with the selection. Otherwise, search begins
|
||||||
|
at the insert mark.
|
||||||
|
|
||||||
|
To aid progress, the search functions do not return an empty
|
||||||
|
match at the starting position unless ok is True.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
if not prog:
|
if not prog:
|
||||||
|
|
@ -188,15 +189,18 @@ class SearchEngine:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def search_reverse(prog, chars, col):
|
def search_reverse(prog, chars, col):
|
||||||
'''Search backwards in a string (line of text).
|
'''Search backwards and return an re match object or None.
|
||||||
|
|
||||||
This is done by searching forwards until there is no match.
|
This is done by searching forwards until there is no match.
|
||||||
|
Prog: compiled re object with a search method returning a match.
|
||||||
|
Chars: line of text, without \n.
|
||||||
|
Col: stop index for the search; the limit for match.end().
|
||||||
'''
|
'''
|
||||||
m = prog.search(chars)
|
m = prog.search(chars)
|
||||||
if not m:
|
if not m:
|
||||||
return None
|
return None
|
||||||
found = None
|
found = None
|
||||||
i, j = m.span()
|
i, j = m.span() # m.start(), m.end() == match slice indexes
|
||||||
while i < col and j <= col:
|
while i < col and j <= col:
|
||||||
found = m
|
found = m
|
||||||
if i == j:
|
if i == j:
|
||||||
|
|
@ -226,7 +230,7 @@ def get_line_col(index):
|
||||||
line, col = map(int, index.split(".")) # Fails on invalid index
|
line, col = map(int, index.split(".")) # Fails on invalid index
|
||||||
return line, col
|
return line, col
|
||||||
|
|
||||||
##if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
## from test import support; support.use_resources = ['gui']
|
from test import support; support.use_resources = ['gui']
|
||||||
## import unittest
|
import unittest
|
||||||
## unittest.main('idlelib.idle_test.test_searchengine', verbosity=2, exit=False)
|
unittest.main('idlelib.idle_test.test_searchengine', verbosity=2, exit=False)
|
||||||
|
|
|
||||||
326
Lib/idlelib/idle_test/test_searchengine.py
Normal file
326
Lib/idlelib/idle_test/test_searchengine.py
Normal file
|
|
@ -0,0 +1,326 @@
|
||||||
|
'''Test functions and SearchEngine class in SearchEngine.py.'''
|
||||||
|
|
||||||
|
# With mock replacements, the module does not use any gui widgets.
|
||||||
|
# The use of tk.Text is avoided (for now, until mock Text is improved)
|
||||||
|
# by patching instances with an index function returning what is needed.
|
||||||
|
# This works because mock Text.get does not use .index.
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
from test.support import requires
|
||||||
|
from tkinter import BooleanVar, StringVar, TclError # ,Tk, Text
|
||||||
|
import tkinter.messagebox as tkMessageBox
|
||||||
|
from idlelib import SearchEngine as se
|
||||||
|
from idlelib.idle_test.mock_tk import Var, Mbox
|
||||||
|
from idlelib.idle_test.mock_tk import Text as mockText
|
||||||
|
|
||||||
|
def setUpModule():
|
||||||
|
# Replace s-e module tkinter imports other than non-gui TclError.
|
||||||
|
se.BooleanVar = Var
|
||||||
|
se.StringVar = Var
|
||||||
|
se.tkMessageBox = Mbox
|
||||||
|
|
||||||
|
def tearDownModule():
|
||||||
|
# Restore 'just in case', though other tests should also replace.
|
||||||
|
se.BooleanVar = BooleanVar
|
||||||
|
se.StringVar = StringVar
|
||||||
|
se.tkMessageBox = tkMessageBox
|
||||||
|
|
||||||
|
|
||||||
|
class Mock:
|
||||||
|
def __init__(self, *args, **kwargs): pass
|
||||||
|
|
||||||
|
class GetTest(unittest.TestCase):
|
||||||
|
# SearchEngine.get returns singleton created & saved on first call.
|
||||||
|
def test_get(self):
|
||||||
|
saved_Engine = se.SearchEngine
|
||||||
|
se.SearchEngine = Mock # monkey-patch class
|
||||||
|
try:
|
||||||
|
root = Mock()
|
||||||
|
engine = se.get(root)
|
||||||
|
self.assertIsInstance(engine, se.SearchEngine)
|
||||||
|
self.assertIs(root._searchengine, engine)
|
||||||
|
self.assertIs(se.get(root), engine)
|
||||||
|
finally:
|
||||||
|
se.SearchEngine = saved_Engine # restore class to module
|
||||||
|
|
||||||
|
class GetLineColTest(unittest.TestCase):
|
||||||
|
# Test simple text-independent helper function
|
||||||
|
def test_get_line_col(self):
|
||||||
|
self.assertEqual(se.get_line_col('1.0'), (1, 0))
|
||||||
|
self.assertEqual(se.get_line_col('1.11'), (1, 11))
|
||||||
|
|
||||||
|
self.assertRaises(ValueError, se.get_line_col, ('1.0 lineend'))
|
||||||
|
self.assertRaises(ValueError, se.get_line_col, ('end'))
|
||||||
|
|
||||||
|
class GetSelectionTest(unittest.TestCase):
|
||||||
|
# Test text-dependent helper function.
|
||||||
|
## # Need gui for text.index('sel.first/sel.last/insert').
|
||||||
|
## @classmethod
|
||||||
|
## def setUpClass(cls):
|
||||||
|
## requires('gui')
|
||||||
|
## cls.root = Tk()
|
||||||
|
##
|
||||||
|
## @classmethod
|
||||||
|
## def tearDownClass(cls):
|
||||||
|
## cls.root.destroy()
|
||||||
|
|
||||||
|
def test_get_selection(self):
|
||||||
|
# text = Text(master=self.root)
|
||||||
|
text = mockText()
|
||||||
|
text.insert('1.0', 'Hello World!')
|
||||||
|
|
||||||
|
# fix text.index result when called in get_selection
|
||||||
|
def sel(s):
|
||||||
|
# select entire text, cursor irrelevant
|
||||||
|
if s == 'sel.first': return '1.0'
|
||||||
|
if s == 'sel.last': return '1.12'
|
||||||
|
raise TclError
|
||||||
|
text.index = sel # replaces .tag_add('sel', '1.0, '1.12')
|
||||||
|
self.assertEqual(se.get_selection(text), ('1.0', '1.12'))
|
||||||
|
|
||||||
|
def mark(s):
|
||||||
|
# no selection, cursor after 'Hello'
|
||||||
|
if s == 'insert': return '1.5'
|
||||||
|
raise TclError
|
||||||
|
text.index = mark # replaces .mark_set('insert', '1.5')
|
||||||
|
self.assertEqual(se.get_selection(text), ('1.5', '1.5'))
|
||||||
|
|
||||||
|
|
||||||
|
class ReverseSearchTest(unittest.TestCase):
|
||||||
|
# Test helper function that searches backwards within a line.
|
||||||
|
def test_search_reverse(self):
|
||||||
|
Equal = self.assertEqual
|
||||||
|
line = "Here is an 'is' test text."
|
||||||
|
prog = re.compile('is')
|
||||||
|
Equal(se.search_reverse(prog, line, len(line)).span(), (12, 14))
|
||||||
|
Equal(se.search_reverse(prog, line, 14).span(), (12, 14))
|
||||||
|
Equal(se.search_reverse(prog, line, 13).span(), (5, 7))
|
||||||
|
Equal(se.search_reverse(prog, line, 7).span(), (5, 7))
|
||||||
|
Equal(se.search_reverse(prog, line, 6), None)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchEngineTest(unittest.TestCase):
|
||||||
|
# Test class methods that do not use Text widget.
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.engine = se.SearchEngine(root=None)
|
||||||
|
# Engine.root is only used to create error message boxes.
|
||||||
|
# The mock replacement ignores the root argument.
|
||||||
|
|
||||||
|
def test_is_get(self):
|
||||||
|
engine = self.engine
|
||||||
|
Equal = self.assertEqual
|
||||||
|
|
||||||
|
Equal(engine.getpat(), '')
|
||||||
|
engine.setpat('hello')
|
||||||
|
Equal(engine.getpat(), 'hello')
|
||||||
|
|
||||||
|
Equal(engine.isre(), False)
|
||||||
|
engine.revar.set(1)
|
||||||
|
Equal(engine.isre(), True)
|
||||||
|
|
||||||
|
Equal(engine.iscase(), False)
|
||||||
|
engine.casevar.set(1)
|
||||||
|
Equal(engine.iscase(), True)
|
||||||
|
|
||||||
|
Equal(engine.isword(), False)
|
||||||
|
engine.wordvar.set(1)
|
||||||
|
Equal(engine.isword(), True)
|
||||||
|
|
||||||
|
Equal(engine.iswrap(), True)
|
||||||
|
engine.wrapvar.set(0)
|
||||||
|
Equal(engine.iswrap(), False)
|
||||||
|
|
||||||
|
Equal(engine.isback(), False)
|
||||||
|
engine.backvar.set(1)
|
||||||
|
Equal(engine.isback(), True)
|
||||||
|
|
||||||
|
def test_setcookedpat(self):
|
||||||
|
engine = self.engine
|
||||||
|
engine.setcookedpat('\s')
|
||||||
|
self.assertEqual(engine.getpat(), '\s')
|
||||||
|
engine.revar.set(1)
|
||||||
|
engine.setcookedpat('\s')
|
||||||
|
self.assertEqual(engine.getpat(), r'\\s')
|
||||||
|
|
||||||
|
def test_getcookedpat(self):
|
||||||
|
engine = self.engine
|
||||||
|
Equal = self.assertEqual
|
||||||
|
|
||||||
|
Equal(engine.getcookedpat(), '')
|
||||||
|
engine.setpat('hello')
|
||||||
|
Equal(engine.getcookedpat(), 'hello')
|
||||||
|
engine.wordvar.set(True)
|
||||||
|
Equal(engine.getcookedpat(), r'\bhello\b')
|
||||||
|
engine.wordvar.set(False)
|
||||||
|
|
||||||
|
engine.setpat('\s')
|
||||||
|
Equal(engine.getcookedpat(), r'\\s')
|
||||||
|
engine.revar.set(True)
|
||||||
|
Equal(engine.getcookedpat(), '\s')
|
||||||
|
|
||||||
|
def test_getprog(self):
|
||||||
|
engine = self.engine
|
||||||
|
Equal = self.assertEqual
|
||||||
|
|
||||||
|
engine.setpat('Hello')
|
||||||
|
temppat = engine.getprog()
|
||||||
|
Equal(temppat.pattern, re.compile('Hello', re.IGNORECASE).pattern)
|
||||||
|
engine.casevar.set(1)
|
||||||
|
temppat = engine.getprog()
|
||||||
|
Equal(temppat.pattern, re.compile('Hello').pattern, 0)
|
||||||
|
|
||||||
|
engine.setpat('')
|
||||||
|
Equal(engine.getprog(), None)
|
||||||
|
engine.setpat('+')
|
||||||
|
engine.revar.set(1)
|
||||||
|
Equal(engine.getprog(), None)
|
||||||
|
self.assertEqual(Mbox.showerror.message,
|
||||||
|
'Error: nothing to repeat\nPattern: +')
|
||||||
|
|
||||||
|
def test_report_error(self):
|
||||||
|
showerror = Mbox.showerror
|
||||||
|
Equal = self.assertEqual
|
||||||
|
pat = '[a-z'
|
||||||
|
msg = 'unexpected end of regular expression'
|
||||||
|
|
||||||
|
Equal(self.engine.report_error(pat, msg), None)
|
||||||
|
Equal(showerror.title, 'Regular expression error')
|
||||||
|
expected_message = ("Error: " + msg + "\nPattern: [a-z")
|
||||||
|
Equal(showerror.message, expected_message)
|
||||||
|
|
||||||
|
Equal(self.engine.report_error(pat, msg, 5), None)
|
||||||
|
Equal(showerror.title, 'Regular expression error')
|
||||||
|
expected_message += "\nOffset: 5"
|
||||||
|
Equal(showerror.message, expected_message)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchTest(unittest.TestCase):
|
||||||
|
# Test that search_text makes right call to right method.
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
## requires('gui')
|
||||||
|
## cls.root = Tk()
|
||||||
|
## cls.text = Text(master=cls.root)
|
||||||
|
cls.text = mockText()
|
||||||
|
test_text = (
|
||||||
|
'First line\n'
|
||||||
|
'Line with target\n'
|
||||||
|
'Last line\n')
|
||||||
|
cls.text.insert('1.0', test_text)
|
||||||
|
cls.pat = re.compile('target')
|
||||||
|
|
||||||
|
cls.engine = se.SearchEngine(None)
|
||||||
|
cls.engine.search_forward = lambda *args: ('f', args)
|
||||||
|
cls.engine.search_backward = lambda *args: ('b', args)
|
||||||
|
|
||||||
|
## @classmethod
|
||||||
|
## def tearDownClass(cls):
|
||||||
|
## cls.root.destroy()
|
||||||
|
|
||||||
|
def test_search(self):
|
||||||
|
Equal = self.assertEqual
|
||||||
|
engine = self.engine
|
||||||
|
search = engine.search_text
|
||||||
|
text = self.text
|
||||||
|
pat = self.pat
|
||||||
|
|
||||||
|
engine.patvar.set(None)
|
||||||
|
#engine.revar.set(pat)
|
||||||
|
Equal(search(text), None)
|
||||||
|
|
||||||
|
def mark(s):
|
||||||
|
# no selection, cursor after 'Hello'
|
||||||
|
if s == 'insert': return '1.5'
|
||||||
|
raise TclError
|
||||||
|
text.index = mark
|
||||||
|
Equal(search(text, pat), ('f', (text, pat, 1, 5, True, False)))
|
||||||
|
engine.wrapvar.set(False)
|
||||||
|
Equal(search(text, pat), ('f', (text, pat, 1, 5, False, False)))
|
||||||
|
engine.wrapvar.set(True)
|
||||||
|
engine.backvar.set(True)
|
||||||
|
Equal(search(text, pat), ('b', (text, pat, 1, 5, True, False)))
|
||||||
|
engine.backvar.set(False)
|
||||||
|
|
||||||
|
def sel(s):
|
||||||
|
if s == 'sel.first': return '2.10'
|
||||||
|
if s == 'sel.last': return '2.16'
|
||||||
|
raise TclError
|
||||||
|
text.index = sel
|
||||||
|
Equal(search(text, pat), ('f', (text, pat, 2, 16, True, False)))
|
||||||
|
Equal(search(text, pat, True), ('f', (text, pat, 2, 10, True, True)))
|
||||||
|
engine.backvar.set(True)
|
||||||
|
Equal(search(text, pat), ('b', (text, pat, 2, 10, True, False)))
|
||||||
|
Equal(search(text, pat, True), ('b', (text, pat, 2, 16, True, True)))
|
||||||
|
|
||||||
|
|
||||||
|
class ForwardBackwardTest(unittest.TestCase):
|
||||||
|
# Test that search_forward method finds the target.
|
||||||
|
## @classmethod
|
||||||
|
## def tearDownClass(cls):
|
||||||
|
## cls.root.destroy()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.engine = se.SearchEngine(None)
|
||||||
|
## requires('gui')
|
||||||
|
## cls.root = Tk()
|
||||||
|
## cls.text = Text(master=cls.root)
|
||||||
|
cls.text = mockText()
|
||||||
|
# search_backward calls index('end-1c')
|
||||||
|
cls.text.index = lambda index: '4.0'
|
||||||
|
test_text = (
|
||||||
|
'First line\n'
|
||||||
|
'Line with target\n'
|
||||||
|
'Last line\n')
|
||||||
|
cls.text.insert('1.0', test_text)
|
||||||
|
cls.pat = re.compile('target')
|
||||||
|
cls.res = (2, (10, 16)) # line, slice indexes of 'target'
|
||||||
|
cls.failpat = re.compile('xyz') # not in text
|
||||||
|
cls.emptypat = re.compile('\w*') # empty match possible
|
||||||
|
|
||||||
|
def make_search(self, func):
|
||||||
|
def search(pat, line, col, wrap, ok=0):
|
||||||
|
res = func(self.text, pat, line, col, wrap, ok)
|
||||||
|
# res is (line, matchobject) or None
|
||||||
|
return (res[0], res[1].span()) if res else res
|
||||||
|
return search
|
||||||
|
|
||||||
|
def test_search_forward(self):
|
||||||
|
# search for non-empty match
|
||||||
|
Equal = self.assertEqual
|
||||||
|
forward = self.make_search(self.engine.search_forward)
|
||||||
|
pat = self.pat
|
||||||
|
Equal(forward(pat, 1, 0, True), self.res)
|
||||||
|
Equal(forward(pat, 3, 0, True), self.res) # wrap
|
||||||
|
Equal(forward(pat, 3, 0, False), None) # no wrap
|
||||||
|
Equal(forward(pat, 2, 10, False), self.res)
|
||||||
|
|
||||||
|
Equal(forward(self.failpat, 1, 0, True), None)
|
||||||
|
Equal(forward(self.emptypat, 2, 9, True, ok=True), (2, (9, 9)))
|
||||||
|
#Equal(forward(self.emptypat, 2, 9, True), self.res)
|
||||||
|
# While the initial empty match is correctly ignored, skipping
|
||||||
|
# the rest of the line and returning (3, (0,4)) seems buggy - tjr.
|
||||||
|
Equal(forward(self.emptypat, 2, 10, True), self.res)
|
||||||
|
|
||||||
|
def test_search_backward(self):
|
||||||
|
# search for non-empty match
|
||||||
|
Equal = self.assertEqual
|
||||||
|
backward = self.make_search(self.engine.search_backward)
|
||||||
|
pat = self.pat
|
||||||
|
Equal(backward(pat, 3, 5, True), self.res)
|
||||||
|
Equal(backward(pat, 2, 0, True), self.res) # wrap
|
||||||
|
Equal(backward(pat, 2, 0, False), None) # no wrap
|
||||||
|
Equal(backward(pat, 2, 16, False), self.res)
|
||||||
|
|
||||||
|
Equal(backward(self.failpat, 3, 9, True), None)
|
||||||
|
Equal(backward(self.emptypat, 2, 10, True, ok=True), (2, (9,9)))
|
||||||
|
# Accepted because 9 < 10, not because ok=True.
|
||||||
|
# It is not clear that ok=True is useful going back - tjr
|
||||||
|
Equal(backward(self.emptypat, 2, 9, True), (2, (5, 9)))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main(verbosity=2, exit=2)
|
||||||
|
|
@ -177,6 +177,11 @@ Tests
|
||||||
possible, since "localhost" goes through a DNS lookup under recent Windows
|
possible, since "localhost" goes through a DNS lookup under recent Windows
|
||||||
versions.
|
versions.
|
||||||
|
|
||||||
|
IDLE
|
||||||
|
----
|
||||||
|
|
||||||
|
- Issue #18489: Add tests for SearchEngine. Original patch by Phil Webster.
|
||||||
|
|
||||||
Documentation
|
Documentation
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue