Search spelling suggestions across every list
This commit is contained in:
191
tests/test_core/test_spellfix.py
Normal file
191
tests/test_core/test_spellfix.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""Correcting a misspelled search word without wrecking an identifier.
|
||||
|
||||
Pure arithmetic over a word list, so it is tested that way. The cases that
|
||||
matter most are the ones where it must REFUSE: a wrong suggestion is acted on,
|
||||
and here that means the wrong bay or the wrong cartridge.
|
||||
"""
|
||||
|
||||
from shopdb.core.services.spellfix import (
|
||||
Vocabulary, damerau_levenshtein, max_distance,
|
||||
)
|
||||
|
||||
SHOPFLOOR = [
|
||||
'Keyence vision system', 'Genspect', 'Telesis part marker',
|
||||
'wax-trace', 'Zebra ZT411', 'calibration', 'Haas Automation',
|
||||
'CSF16', 'CSF17', 'MT-1042', 'FC733SY3', 'printer', 'grinder',
|
||||
]
|
||||
|
||||
|
||||
def vocab():
|
||||
return Vocabulary(SHOPFLOOR)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ distance
|
||||
|
||||
def test_a_transposition_costs_one_edit_not_two():
|
||||
"""The commonest typing error. Plain Levenshtein charges two, which pushes
|
||||
a real typo past the threshold on a short word."""
|
||||
assert damerau_levenshtein('keyecne', 'keyence') == 1
|
||||
|
||||
|
||||
def test_distance_allowed_scales_with_length():
|
||||
assert max_distance('bay') == 1
|
||||
assert max_distance('grinder') == 2
|
||||
assert max_distance('calibration') == 3
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ suggests
|
||||
|
||||
def test_it_corrects_vocabulary_no_dictionary_would_hold():
|
||||
assert vocab().suggest('keyecne') == 'keyence'
|
||||
assert vocab().suggest('genspct') == 'genspect'
|
||||
assert vocab().suggest('telisis') == 'telesis'
|
||||
|
||||
|
||||
def test_a_word_that_is_already_right_is_left_alone():
|
||||
assert vocab().suggest('keyence') is None
|
||||
assert vocab().suggest('printer') is None
|
||||
|
||||
|
||||
def test_a_word_nothing_resembles_gets_no_guess():
|
||||
"""A wrong suggestion costs more than no suggestion."""
|
||||
assert vocab().suggest('helicopter') is None
|
||||
|
||||
|
||||
# ------------------------------------------------------- identifier guards
|
||||
|
||||
def test_an_asset_number_is_never_corrected_to_a_different_one():
|
||||
"""THE case this exists for. CSF16 to CSF17 is one edit and a different bay.
|
||||
|
||||
Both are in the vocabulary, so without the digit guard the near miss is
|
||||
exactly the kind of thing edit distance loves to 'fix'.
|
||||
"""
|
||||
assert vocab().suggest('csf18') is None
|
||||
assert vocab().suggest('mt-1043') is None
|
||||
|
||||
|
||||
def test_a_typo_in_the_letters_of_an_identifier_still_corrects():
|
||||
"""The guard is on the DIGITS, not on identifiers as a whole: the digits
|
||||
have to match, and here they do."""
|
||||
assert vocab().suggest('cfs16') == 'csf16'
|
||||
|
||||
|
||||
def test_the_first_character_must_match():
|
||||
"""Typos land in the middle and at the end. Requiring the first key costs
|
||||
little recall and removes most of the nonsense."""
|
||||
assert vocab().suggest('teyence') is None
|
||||
|
||||
|
||||
def test_a_tie_produces_no_suggestion():
|
||||
"""Two equally good answers means there is no answer. CSF16 and CSF17 are
|
||||
both one edit from CSF1x, and offering either implies a confidence that is
|
||||
not there - but the digit guard rejects both first, so build a case where
|
||||
only the tie is in play."""
|
||||
tied = Vocabulary(['barn', 'bard'])
|
||||
assert tied.suggest('bare') is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- queries
|
||||
|
||||
def test_a_multi_word_query_corrects_only_what_is_wrong():
|
||||
assert vocab().suggest_query('keyecne calibration') == 'keyence calibration'
|
||||
|
||||
|
||||
def test_a_query_that_is_entirely_fine_returns_none():
|
||||
assert vocab().suggest_query('keyence calibration') is None
|
||||
|
||||
|
||||
def test_short_words_are_left_alone():
|
||||
"""Below three characters there is not enough word to correct."""
|
||||
assert vocab().suggest('cs') is None
|
||||
assert vocab().suggest_query('cs') is None
|
||||
|
||||
|
||||
def test_the_vocabulary_comes_from_the_data():
|
||||
"""A vendor added this morning is correctable this morning."""
|
||||
fresh = vocab()
|
||||
assert fresh.suggest('renishw') is None
|
||||
fresh.add('Renishaw')
|
||||
assert fresh.suggest('renishw') == 'renishaw'
|
||||
|
||||
|
||||
# ------------------------------------------------------- wired into search
|
||||
|
||||
def test_a_search_that_finds_nothing_offers_a_spelling(client, db, auth_headers):
|
||||
"""End to end: the vocabulary is harvested from real rows, and a misspelled
|
||||
query that returns no results comes back with a better spelling."""
|
||||
from shopdb.core.models import Vendor
|
||||
from shopdb.extensions import cache
|
||||
|
||||
db.session.add(Vendor(vendor='Keyence'))
|
||||
db.session.commit()
|
||||
cache.delete('search_vocabulary')
|
||||
|
||||
resp = client.get('/api/search?q=keyecne', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()['data']
|
||||
assert data['results'] == []
|
||||
assert data.get('suggestion') == 'keyence'
|
||||
|
||||
|
||||
def test_a_search_that_finds_something_offers_nothing(client, db, auth_headers):
|
||||
from shopdb.core.models import Vendor
|
||||
from shopdb.extensions import cache
|
||||
|
||||
db.session.add(Vendor(vendor='Keyence'))
|
||||
db.session.commit()
|
||||
cache.delete('search_vocabulary')
|
||||
|
||||
resp = client.get('/api/search?q=keyence', headers=auth_headers)
|
||||
assert 'suggestion' not in resp.get_json()['data']
|
||||
|
||||
|
||||
def test_a_mistyped_asset_number_is_not_corrected_to_a_real_one(
|
||||
client, db, auth_headers):
|
||||
"""The guard, through the endpoint. CSF17 exists; CSF18 does not; and the
|
||||
answer to "CSF18" must be no results and NO suggestion, not a different
|
||||
bay."""
|
||||
from shopdb.core.models import Asset, AssetType
|
||||
from shopdb.extensions import cache
|
||||
|
||||
assettype = AssetType.query.first() or AssetType(assettype='machine')
|
||||
if not assettype.assettypeid:
|
||||
db.session.add(assettype)
|
||||
db.session.flush()
|
||||
db.session.add(Asset(assetnumber='CSF17', assettypeid=assettype.assettypeid))
|
||||
db.session.commit()
|
||||
cache.delete('search_vocabulary')
|
||||
|
||||
resp = client.get('/api/search?q=CSF18', headers=auth_headers)
|
||||
data = resp.get_json()['data']
|
||||
assert data['results'] == []
|
||||
assert 'suggestion' not in data
|
||||
|
||||
|
||||
def test_the_suggest_endpoint_serves_any_page(client, db, auth_headers):
|
||||
"""The shared endpoint every list uses, rather than a suggestion key added
|
||||
to thirty search routes."""
|
||||
from shopdb.core.models import Vendor
|
||||
from shopdb.extensions import cache
|
||||
|
||||
db.session.add(Vendor(vendor='Keyence'))
|
||||
db.session.commit()
|
||||
cache.delete('search_vocabulary')
|
||||
|
||||
resp = client.get('/api/search/suggest?q=keyecne', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['suggestion'] == 'keyence'
|
||||
|
||||
|
||||
def test_the_suggest_endpoint_says_null_rather_than_guessing(client, db,
|
||||
auth_headers):
|
||||
from shopdb.extensions import cache
|
||||
cache.delete('search_vocabulary')
|
||||
resp = client.get('/api/search/suggest?q=helicopter', headers=auth_headers)
|
||||
assert resp.get_json()['data']['suggestion'] is None
|
||||
|
||||
|
||||
def test_the_suggest_endpoint_ignores_a_too_short_query(client, auth_headers):
|
||||
resp = client.get('/api/search/suggest?q=c', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['suggestion'] is None
|
||||
Reference in New Issue
Block a user