Skip to content

Commit 7be3d45

Browse files
authored
Merge pull request #528 from pyinat/autocomplete
Fixes for taxon autocomplete
2 parents 95d833f + ad70a63 commit 7be3d45

File tree

3 files changed

+169
-3
lines changed

3 files changed

+169
-3
lines changed

HISTORY.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
* Fix error when observation has no taxon
1111
* Fix error when observation has no `place_guess`
1212
* Fix error when searching taxa by name
13-
* Fix error on multiple `preferred_common_name` values
13+
* Fix error when multiple `Taxon.preferred_common_name` values are present
14+
* Fix error when entering special characters in taxon autocomplete search
15+
* Fix empty results for some search sequences in taxon autocomplete search
1416

1517
## 0.9.1 (2026-03-07)
1618

naturtag/widgets/autocomplete.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import re
12
from logging import getLogger
23

34
from pyinaturalist_convert import TaxonAutocompleter
@@ -6,6 +7,8 @@
67

78
from naturtag.widgets.style import fa_icon
89

10+
INVALID_FTS5_CHARS = re.compile(r'[^\w\s\-\'\.]')
11+
912
logger = getLogger(__name__)
1013

1114

@@ -24,6 +27,7 @@ def __init__(self):
2427
self.setClearButtonEnabled(True)
2528
self.findChild(QToolButton).setIcon(fa_icon('mdi.backspace'))
2629
self.taxa: dict[str, int] = {}
30+
self._last_query: str = ''
2731

2832
completer = QCompleter()
2933
completer.setCaseSensitivity(Qt.CaseInsensitive)
@@ -61,13 +65,14 @@ def _schedule_search(self, q: str):
6165

6266
def _do_search(self):
6367
"""Execute the search after the debounce delay"""
64-
q = self._pending_query
65-
if len(q) > 1 and q not in self.taxa:
68+
q = INVALID_FTS5_CHARS.sub('', self._pending_query).strip()
69+
if len(q) > 1 and q != self._last_query:
6670
from naturtag.controllers import get_app
6771

6872
app = get_app()
6973
language = app.settings.locale if app.settings.search_locale else None
7074
results = self.taxon_completer.search(q, language=language)
75+
self._last_query = q
7176
self.taxa = {t.name: t.id for t in results}
7277
self.model.setStringList(self.taxa.keys())
7378

test/widgets/test_autocomplete.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""Tests for TaxonAutocomplete widget."""
2+
3+
from types import SimpleNamespace
4+
from unittest.mock import patch
5+
6+
import pytest
7+
from PySide6.QtCore import Qt
8+
from PySide6.QtGui import QKeyEvent
9+
10+
from naturtag.widgets.autocomplete import TaxonAutocomplete
11+
12+
13+
def _make_result(name: str, taxon_id: int) -> SimpleNamespace:
14+
return SimpleNamespace(name=name, id=taxon_id)
15+
16+
17+
@pytest.fixture
18+
def autocomplete(qtbot, mock_app):
19+
mock_app.settings.search_locale = False
20+
21+
with patch('naturtag.widgets.autocomplete.TaxonAutocompleter'):
22+
widget = TaxonAutocomplete()
23+
24+
qtbot.addWidget(widget)
25+
return widget
26+
27+
28+
def test_initial_state(autocomplete):
29+
assert autocomplete.taxa == {}
30+
assert autocomplete._last_query == ''
31+
assert autocomplete.model.stringList() == []
32+
33+
34+
@pytest.mark.parametrize(
35+
'query,expect_search',
36+
[
37+
('c', False),
38+
('ca', True),
39+
],
40+
ids=['single-char', 'two-chars'],
41+
)
42+
def test_do_search__length_threshold(autocomplete, query, expect_search):
43+
autocomplete.taxon_completer.search.return_value = [_make_result('cat', 1)]
44+
autocomplete._pending_query = query
45+
autocomplete._do_search()
46+
if expect_search:
47+
autocomplete.taxon_completer.search.assert_called_once_with(query, language=None)
48+
assert autocomplete._last_query == query
49+
assert autocomplete.taxa == {'cat': 1}
50+
else:
51+
autocomplete.taxon_completer.search.assert_not_called()
52+
53+
54+
def test_do_search__duplicate(autocomplete):
55+
autocomplete.taxon_completer.search.return_value = [_make_result('cat', 1)]
56+
autocomplete._pending_query = 'ca'
57+
autocomplete._do_search()
58+
autocomplete._do_search()
59+
autocomplete.taxon_completer.search.assert_called_once()
60+
61+
62+
@pytest.mark.parametrize(
63+
'raw_query,expected_query',
64+
[
65+
('"acer"', 'acer'),
66+
('Ca(t', 'Cat'),
67+
('Quercus robur', 'Quercus robur'),
68+
('some^query', 'somequery'),
69+
],
70+
ids=['quotes', 'parens', 'valid-name', 'caret'],
71+
)
72+
def test_do_search__sanitize_input(autocomplete, raw_query, expected_query):
73+
autocomplete.taxon_completer.search.return_value = []
74+
autocomplete._pending_query = raw_query
75+
autocomplete._do_search()
76+
autocomplete.taxon_completer.search.assert_called_once_with(expected_query, language=None)
77+
78+
79+
def test_do_search__sanitize_input__empty(autocomplete):
80+
"""A query that strips down to <=1 char should not trigger a search"""
81+
autocomplete._pending_query = '\\'
82+
autocomplete._do_search()
83+
autocomplete.taxon_completer.search.assert_not_called()
84+
85+
86+
@pytest.mark.parametrize(
87+
'name,expected',
88+
[
89+
('Catfish', [42]),
90+
('Unknown', []),
91+
],
92+
ids=['known-name', 'unknown-name'],
93+
)
94+
def test_select_taxon(autocomplete, name, expected):
95+
autocomplete.taxa = {'Catfish': 42}
96+
received = []
97+
autocomplete.on_select.connect(received.append)
98+
autocomplete.select_taxon(name)
99+
assert received == expected
100+
101+
102+
def test_do_search__passes_language_when_search_locale_enabled(autocomplete, mock_app):
103+
"""When search_locale is True, the settings locale is forwarded to the completer."""
104+
mock_app.settings.search_locale = True
105+
mock_app.settings.locale = 'fr'
106+
autocomplete.taxon_completer.search.return_value = []
107+
autocomplete._pending_query = 'qu'
108+
autocomplete._do_search()
109+
autocomplete.taxon_completer.search.assert_called_once_with('qu', language='fr')
110+
111+
112+
def test_do_search__model_string_list(autocomplete):
113+
autocomplete.taxon_completer.search.return_value = [
114+
_make_result('Quercus robur', 10),
115+
_make_result('Quercus alba', 11),
116+
]
117+
autocomplete._pending_query = 'qu'
118+
autocomplete._do_search()
119+
assert set(autocomplete.model.stringList()) == {'Quercus robur', 'Quercus alba'}
120+
121+
122+
def test_do_search__clear(autocomplete):
123+
"""An empty result set clears any previously shown completions"""
124+
autocomplete.taxon_completer.search.return_value = [_make_result('Quercus', 10)]
125+
autocomplete._pending_query = 'qu'
126+
autocomplete._do_search()
127+
128+
autocomplete.taxon_completer.search.return_value = []
129+
autocomplete._pending_query = 'qx'
130+
autocomplete._do_search()
131+
assert autocomplete.model.stringList() == []
132+
133+
134+
def test_schedule_search__debounce(autocomplete):
135+
"""Each call stores the latest query and (re)starts the debounce timer"""
136+
autocomplete._schedule_search('ab')
137+
autocomplete._schedule_search('abc')
138+
assert autocomplete._pending_query == 'abc'
139+
assert autocomplete._search_timer.isActive()
140+
autocomplete._search_timer.stop()
141+
142+
143+
@pytest.mark.parametrize(
144+
'key,expect_emit',
145+
[
146+
(Qt.Key_Tab, True),
147+
(Qt.Key_A, False),
148+
],
149+
ids=['tab', 'non-tab'],
150+
)
151+
def test_key_event(autocomplete, key, expect_emit):
152+
"""Tab key emits on_tab and is consumed; other keys do not emit on_tab"""
153+
emitted = []
154+
autocomplete.on_tab.connect(lambda: emitted.append(True))
155+
key_event = QKeyEvent(QKeyEvent.Type.KeyPress, key, Qt.NoModifier)
156+
consumed = autocomplete.event(key_event)
157+
assert bool(emitted) is expect_emit
158+
if expect_emit:
159+
assert consumed is True

0 commit comments

Comments
 (0)