Files
ComfyUI-Lora-Manager/tests/services/test_recipe_fts_index.py
Will Miao 17c5583297 fix(fts): fix multi-word field-restricted search query building
Fixes a critical bug in FTS query building where multi-word searches
with field restrictions incorrectly used OR between all word+field
combinations instead of requiring ALL words to match within at least
one field.

Example: searching "cute cat" in {title, tags} previously produced:
  title:cute* OR title:cat* OR tags:cute* OR tags:cat*
Which matched recipes with ANY word in ANY field.

Now produces:
  (title:cute* title:cat*) OR (tags:cute* tags:cat*)
Which requires ALL words to match within at least one field.

Also adds fallback to fuzzy search when FTS returns empty results,
improving search reliability.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 10:25:03 +08:00

519 lines
19 KiB
Python

"""Tests for RecipeFTSIndex service."""
import os
import pytest
import tempfile
import time
from pathlib import Path
from py.services.recipe_fts_index import RecipeFTSIndex
@pytest.fixture
def temp_db_path(tmp_path):
"""Create a temporary database path for testing."""
return str(tmp_path / "test_recipe_fts.sqlite")
@pytest.fixture
def fts_index(temp_db_path):
"""Create a RecipeFTSIndex instance with a temporary database."""
return RecipeFTSIndex(db_path=temp_db_path)
@pytest.fixture
def sample_recipes():
"""Sample recipe data for testing."""
return [
{
'id': 'recipe-1',
'title': 'Beautiful Sunset Landscape',
'tags': ['landscape', 'sunset', 'photography'],
'loras': [
{'file_name': 'sunset_lora', 'modelName': 'Sunset Style'},
{'file_name': 'landscape_v2', 'modelName': 'Landscape Enhancer'},
],
'gen_params': {
'prompt': '1girl, sunset, beach, golden hour',
'negative_prompt': 'ugly, blurry, low quality',
},
},
{
'id': 'recipe-2',
'title': 'Anime Portrait Style',
'tags': ['anime', 'portrait', 'character'],
'loras': [
{'file_name': 'anime_style_v3', 'modelName': 'Anime Master'},
],
'gen_params': {
'prompt': '1girl, anime style, beautiful eyes, detailed hair',
'negative_prompt': 'worst quality, bad anatomy',
},
},
{
'id': 'recipe-3',
'title': 'Cyberpunk City Night',
'tags': ['cyberpunk', 'city', 'night'],
'loras': [
{'file_name': 'cyberpunk_neon', 'modelName': 'Neon Lights'},
{'file_name': 'city_streets', 'modelName': 'Urban Environments'},
],
'gen_params': {
'prompt': 'cyberpunk city, neon lights, rain, night time',
'negative_prompt': 'daylight, sunny',
},
},
]
class TestRecipeFTSIndexInitialization:
"""Tests for FTS index initialization."""
def test_initialize_creates_database(self, fts_index, temp_db_path):
"""Test that initialize creates the database file."""
fts_index.initialize()
assert os.path.exists(temp_db_path)
def test_initialize_is_idempotent(self, fts_index):
"""Test that calling initialize multiple times is safe."""
fts_index.initialize()
fts_index.initialize()
fts_index.initialize()
assert fts_index._schema_initialized
def test_is_ready_false_before_build(self, fts_index):
"""Test that is_ready returns False before index is built."""
assert not fts_index.is_ready()
def test_get_database_path(self, fts_index, temp_db_path):
"""Test that get_database_path returns the correct path."""
assert fts_index.get_database_path() == temp_db_path
class TestRecipeFTSIndexBuild:
"""Tests for FTS index building."""
def test_build_index_creates_ready_index(self, fts_index, sample_recipes):
"""Test that build_index makes the index ready."""
fts_index.build_index(sample_recipes)
assert fts_index.is_ready()
def test_build_index_counts_recipes(self, fts_index, sample_recipes):
"""Test that build_index indexes all recipes."""
fts_index.build_index(sample_recipes)
assert fts_index.get_indexed_count() == len(sample_recipes)
def test_build_index_empty_list(self, fts_index):
"""Test building index with empty recipe list."""
fts_index.build_index([])
assert fts_index.is_ready()
assert fts_index.get_indexed_count() == 0
def test_build_index_handles_recipes_without_id(self, fts_index):
"""Test that recipes without ID are skipped."""
recipes = [
{'title': 'No ID Recipe', 'tags': ['test']},
{'id': 'valid-id', 'title': 'Valid Recipe', 'tags': ['test']},
]
fts_index.build_index(recipes)
assert fts_index.get_indexed_count() == 1
def test_build_index_handles_missing_fields(self, fts_index):
"""Test that missing optional fields are handled gracefully."""
recipes = [
{'id': 'minimal', 'title': 'Minimal Recipe'},
]
fts_index.build_index(recipes)
assert fts_index.is_ready()
assert fts_index.get_indexed_count() == 1
class TestRecipeFTSIndexSearch:
"""Tests for FTS search functionality."""
def test_search_by_title(self, fts_index, sample_recipes):
"""Test searching by recipe title."""
fts_index.build_index(sample_recipes)
results = fts_index.search('sunset')
assert 'recipe-1' in results
results = fts_index.search('anime')
assert 'recipe-2' in results
def test_search_by_tags(self, fts_index, sample_recipes):
"""Test searching by recipe tags."""
fts_index.build_index(sample_recipes)
results = fts_index.search('landscape')
assert 'recipe-1' in results
results = fts_index.search('cyberpunk')
assert 'recipe-3' in results
def test_search_by_lora_name(self, fts_index, sample_recipes):
"""Test searching by LoRA file name."""
fts_index.build_index(sample_recipes)
results = fts_index.search('anime_style')
assert 'recipe-2' in results
results = fts_index.search('cyberpunk_neon')
assert 'recipe-3' in results
def test_search_by_lora_model_name(self, fts_index, sample_recipes):
"""Test searching by LoRA model name."""
fts_index.build_index(sample_recipes)
results = fts_index.search('Anime Master')
assert 'recipe-2' in results
def test_search_by_prompt(self, fts_index, sample_recipes):
"""Test searching by prompt content."""
fts_index.build_index(sample_recipes)
results = fts_index.search('golden hour')
assert 'recipe-1' in results
results = fts_index.search('neon lights')
assert 'recipe-3' in results
def test_search_prefix_matching(self, fts_index, sample_recipes):
"""Test that prefix matching works."""
fts_index.build_index(sample_recipes)
# 'sun' should match 'sunset'
results = fts_index.search('sun')
assert 'recipe-1' in results
# 'ani' should match 'anime'
results = fts_index.search('ani')
assert 'recipe-2' in results
def test_search_multiple_words(self, fts_index, sample_recipes):
"""Test searching with multiple words (AND logic)."""
fts_index.build_index(sample_recipes)
# Both words must match
results = fts_index.search('city night')
assert 'recipe-3' in results
def test_search_case_insensitive(self, fts_index, sample_recipes):
"""Test that search is case-insensitive."""
fts_index.build_index(sample_recipes)
results_lower = fts_index.search('sunset')
results_upper = fts_index.search('SUNSET')
results_mixed = fts_index.search('SuNsEt')
assert results_lower == results_upper == results_mixed
def test_search_no_results(self, fts_index, sample_recipes):
"""Test search with no matching results."""
fts_index.build_index(sample_recipes)
results = fts_index.search('nonexistent')
assert len(results) == 0
def test_search_empty_query(self, fts_index, sample_recipes):
"""Test search with empty query."""
fts_index.build_index(sample_recipes)
results = fts_index.search('')
assert len(results) == 0
results = fts_index.search(' ')
assert len(results) == 0
def test_search_not_ready_returns_empty(self, fts_index):
"""Test that search returns empty set when index not ready."""
results = fts_index.search('test')
assert len(results) == 0
class TestRecipeFTSIndexFieldRestriction:
"""Tests for field-specific search."""
def test_search_title_only(self, fts_index, sample_recipes):
"""Test searching only in title field."""
fts_index.build_index(sample_recipes)
# 'portrait' appears in title of recipe-2
results = fts_index.search('portrait', fields={'title'})
assert 'recipe-2' in results
def test_search_tags_only(self, fts_index, sample_recipes):
"""Test searching only in tags field."""
fts_index.build_index(sample_recipes)
results = fts_index.search('photography', fields={'tags'})
assert 'recipe-1' in results
def test_search_lora_name_only(self, fts_index, sample_recipes):
"""Test searching only in lora_name field."""
fts_index.build_index(sample_recipes)
results = fts_index.search('sunset_lora', fields={'lora_name'})
assert 'recipe-1' in results
def test_search_prompt_only(self, fts_index, sample_recipes):
"""Test searching only in prompt field."""
fts_index.build_index(sample_recipes)
results = fts_index.search('golden hour', fields={'prompt'})
assert 'recipe-1' in results
# 'ugly' appears in negative_prompt
results = fts_index.search('ugly', fields={'prompt'})
assert 'recipe-1' in results
def test_search_multiple_fields(self, fts_index, sample_recipes):
"""Test searching in multiple fields."""
fts_index.build_index(sample_recipes)
results = fts_index.search('sunset', fields={'title', 'tags'})
assert 'recipe-1' in results
def test_search_multiple_words_field_restricted(self, fts_index):
"""Test that multi-word searches require ALL words to match within at least one field.
This is a regression test for the bug where field-restricted multi-word searches
incorrectly used OR between all word+field combinations, returning recipes that
only matched some of the search words.
"""
# Create recipes that test multi-word matching:
# - recipe-1: both "cute" and "cat" in title
# - recipe-2: only "cute" in title
# - recipe-3: both words split across title and tags (should NOT match when searching title only)
# - recipe-4: both "cute" and "cat" in tags
# - recipe-5: only "cat" in title
test_recipes = [
{
'id': 'recipe-1',
'title': 'cute cat photo',
'tags': ['animal'],
'loras': [],
'gen_params': {},
},
{
'id': 'recipe-2',
'title': 'cute dog picture',
'tags': ['pet'],
'loras': [],
'gen_params': {},
},
{
'id': 'recipe-3',
'title': 'cute',
'tags': ['cat', 'animal'], # "cute" in title, "cat" in tags
'loras': [],
'gen_params': {},
},
{
'id': 'recipe-4',
'title': 'kitten image',
'tags': ['cute', 'cat'], # both words in tags
'loras': [],
'gen_params': {},
},
{
'id': 'recipe-5',
'title': 'cat only',
'tags': [],
'loras': [],
'gen_params': {},
},
]
fts_index.build_index(test_recipes)
# Search "cute cat" in title only - should only match recipe-1 (both words in title)
results = fts_index.search('cute cat', fields={'title'})
assert results == {'recipe-1'}, f"Expected only recipe-1, got {results}"
# Search "cute cat" in tags only - should only match recipe-4 (both words in tags)
results = fts_index.search('cute cat', fields={'tags'})
assert results == {'recipe-4'}, f"Expected only recipe-4, got {results}"
# Search "cute cat" in both title and tags - should match recipe-1 and recipe-4
# (each has both words in one of the specified fields)
results = fts_index.search('cute cat', fields={'title', 'tags'})
assert results == {'recipe-1', 'recipe-4'}, f"Expected recipe-1 and recipe-4, got {results}"
# Search without field restriction - should match recipes where words appear in any indexed field
results = fts_index.search('cute cat')
# recipe-1, recipe-2 (cute), recipe-3 (cute in title, cat in tags), recipe-4, recipe-5 (cat)
# Actually, without field restriction, FTS searches all fields as one bag of content
# So any recipe with both "cute" and "cat" anywhere should match
assert 'recipe-1' in results # both in title
assert 'recipe-4' in results # both in tags
# recipe-3: "cute" in title, "cat" in tags - both words present
assert 'recipe-3' in results
class TestRecipeFTSIndexIncrementalOperations:
"""Tests for incremental add/remove/update operations."""
def test_add_recipe(self, fts_index, sample_recipes):
"""Test adding a single recipe to the index."""
fts_index.build_index(sample_recipes)
initial_count = fts_index.get_indexed_count()
new_recipe = {
'id': 'recipe-new',
'title': 'New Fantasy Scene',
'tags': ['fantasy', 'magic'],
'loras': [{'file_name': 'fantasy_lora', 'modelName': 'Fantasy Style'}],
'gen_params': {'prompt': 'magical forest, wizard'},
}
fts_index.add_recipe(new_recipe)
assert fts_index.get_indexed_count() == initial_count + 1
assert 'recipe-new' in fts_index.search('fantasy')
def test_remove_recipe(self, fts_index, sample_recipes):
"""Test removing a recipe from the index."""
fts_index.build_index(sample_recipes)
initial_count = fts_index.get_indexed_count()
# Verify recipe-1 is searchable
assert 'recipe-1' in fts_index.search('sunset')
# Remove it
fts_index.remove_recipe('recipe-1')
# Verify it's gone
assert fts_index.get_indexed_count() == initial_count - 1
assert 'recipe-1' not in fts_index.search('sunset')
def test_update_recipe(self, fts_index, sample_recipes):
"""Test updating a recipe in the index."""
fts_index.build_index(sample_recipes)
# Update recipe-1 title
updated_recipe = {
'id': 'recipe-1',
'title': 'Tropical Beach Paradise', # Changed from 'Beautiful Sunset Landscape'
'tags': ['beach', 'tropical'], # Changed tags
'loras': sample_recipes[0]['loras'],
'gen_params': sample_recipes[0]['gen_params'],
}
fts_index.update_recipe(updated_recipe)
# Old title should not match
results = fts_index.search('sunset', fields={'title'})
assert 'recipe-1' not in results
# New title should match
results = fts_index.search('tropical', fields={'title'})
assert 'recipe-1' in results
def test_add_recipe_not_ready(self, fts_index):
"""Test that add_recipe returns False when index not ready."""
recipe = {'id': 'test', 'title': 'Test'}
result = fts_index.add_recipe(recipe)
assert result is False
def test_remove_recipe_not_ready(self, fts_index):
"""Test that remove_recipe returns False when index not ready."""
result = fts_index.remove_recipe('test')
assert result is False
class TestRecipeFTSIndexClear:
"""Tests for clearing the FTS index."""
def test_clear_index(self, fts_index, sample_recipes):
"""Test clearing all data from the index."""
fts_index.build_index(sample_recipes)
assert fts_index.get_indexed_count() > 0
fts_index.clear()
assert fts_index.get_indexed_count() == 0
assert not fts_index.is_ready()
class TestRecipeFTSIndexSpecialCharacters:
"""Tests for handling special characters in search."""
def test_search_with_special_characters(self, fts_index):
"""Test that special characters are handled safely."""
recipes = [
{'id': 'r1', 'title': 'Test (with) parentheses', 'tags': []},
{'id': 'r2', 'title': 'Test "with" quotes', 'tags': []},
{'id': 'r3', 'title': 'Test:with:colons', 'tags': []},
]
fts_index.build_index(recipes)
# These should not crash
results = fts_index.search('(with)')
results = fts_index.search('"with"')
results = fts_index.search(':with:')
# Basic word should still match
results = fts_index.search('test')
assert len(results) == 3
def test_search_unicode_characters(self, fts_index):
"""Test searching with unicode characters."""
recipes = [
{'id': 'r1', 'title': '日本語テスト', 'tags': ['anime']},
{'id': 'r2', 'title': 'Émilie résumé café', 'tags': ['french']},
]
fts_index.build_index(recipes)
# Unicode search
results = fts_index.search('日本')
assert 'r1' in results
# Diacritics (depends on tokenizer settings)
results = fts_index.search('cafe') # Should match café due to remove_diacritics
# Note: Result depends on FTS5 configuration
class TestRecipeFTSIndexPerformance:
"""Basic performance tests."""
def test_build_large_index(self, fts_index):
"""Test building index with many recipes."""
recipes = [
{
'id': f'recipe-{i}',
'title': f'Recipe Title {i} with words like sunset landscape anime cyberpunk',
'tags': ['tag1', 'tag2', 'tag3'],
'loras': [{'file_name': f'lora_{i}', 'modelName': f'Model {i}'}],
'gen_params': {'prompt': f'test prompt {i}', 'negative_prompt': 'bad'},
}
for i in range(1000)
]
start_time = time.time()
fts_index.build_index(recipes)
build_time = time.time() - start_time
assert fts_index.is_ready()
assert fts_index.get_indexed_count() == 1000
# Build should complete reasonably fast (under 5 seconds)
assert build_time < 5.0
def test_search_large_index(self, fts_index):
"""Test searching a large index."""
recipes = [
{
'id': f'recipe-{i}',
'title': f'Recipe Title {i}',
'tags': ['common_tag'],
'loras': [],
'gen_params': {},
}
for i in range(1000)
]
fts_index.build_index(recipes)
start_time = time.time()
results = fts_index.search('common_tag')
search_time = time.time() - start_time
assert len(results) == 1000
# Search should be very fast (under 100ms)
assert search_time < 0.1