diff --git a/tests/frontend/components/autocomplete.behavior.test.js b/tests/frontend/components/autocomplete.behavior.test.js index 0f501605..5ebecaaf 100644 --- a/tests/frontend/components/autocomplete.behavior.test.js +++ b/tests/frontend/components/autocomplete.behavior.test.js @@ -267,4 +267,431 @@ describe('AutoComplete widget interactions', () => { const scrollTopAfter = autoComplete.scrollContainer?.scrollTop || 0; expect(scrollTopAfter).toBeGreaterThanOrEqual(scrollTopBefore); }); + + it('replaces entire multi-word phrase when it matches selected tag (Danbooru convention)', async () => { + const mockTags = [ + { tag_name: 'looking_to_the_side', category: 0, post_count: 1234 }, + { tag_name: 'looking_away', category: 0, post_count: 5678 }, + ]; + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, words: mockTags }), + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the side'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = 'looking to the side'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + autoComplete.searchType = 'custom_words'; + autoComplete.activeCommand = null; + autoComplete.items = mockTags; + autoComplete.selectedIndex = 0; + + await autoComplete.insertSelection('looking_to_the_side'); + + expect(input.value).toBe('looking_to_the_side, '); + expect(autoComplete.dropdown.style.display).toBe('none'); + expect(input.focus).toHaveBeenCalled(); + }); + + it('replaces only last token when typing partial match (e.g., "hello 1gi" -> "1girl")', async () => { + const mockTags = [ + { tag_name: '1girl', category: 4, post_count: 500000 }, + { tag_name: '1boy', category: 4, post_count: 300000 }, + ]; + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, words: mockTags }), + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('hello 1gi'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = 'hello 1gi'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + autoComplete.searchType = 'custom_words'; + autoComplete.activeCommand = null; + autoComplete.items = mockTags; + autoComplete.selectedIndex = 0; + autoComplete.currentSearchTerm = 'hello 1gi'; + + await autoComplete.insertSelection('1girl'); + + expect(input.value).toBe('hello 1girl, '); + }); + + it('replaces entire phrase for underscore tag match (e.g., "blue hair" -> "blue_hair")', async () => { + const mockTags = [ + { tag_name: 'blue_hair', category: 0, post_count: 45000 }, + { tag_name: 'blue_eyes', category: 0, post_count: 80000 }, + ]; + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, words: mockTags }), + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('blue hair'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = 'blue hair'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + autoComplete.searchType = 'custom_words'; + autoComplete.activeCommand = null; + autoComplete.items = mockTags; + autoComplete.selectedIndex = 0; + autoComplete.currentSearchTerm = 'blue hair'; + + await autoComplete.insertSelection('blue_hair'); + + expect(input.value).toBe('blue_hair, '); + }); + + it('handles multi-word phrase with preceding text correctly', async () => { + const mockTags = [ + { tag_name: 'looking_to_the_side', category: 0, post_count: 1234 }, + ]; + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, words: mockTags }), + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('1girl, looking to the side'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = '1girl, looking to the side'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + autoComplete.searchType = 'custom_words'; + autoComplete.activeCommand = null; + autoComplete.items = mockTags; + autoComplete.selectedIndex = 0; + autoComplete.currentSearchTerm = 'looking to the side'; + + await autoComplete.insertSelection('looking_to_the_side'); + + expect(input.value).toBe('1girl, looking_to_the_side, '); + }); + + it('replaces entire command and search term when using command mode with multi-word phrase', async () => { + const mockTags = [ + { tag_name: 'looking_to_the_side', category: 4, post_count: 1234 }, + { tag_name: 'looking_away', category: 4, post_count: 5678 }, + ]; + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, words: mockTags }), + }); + + // Simulate "/char looking to the side" input + caretHelperInstance.getBeforeCursor.mockReturnValue('/char looking to the side'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = '/char looking to the side'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + // Set up command mode state + autoComplete.searchType = 'custom_words'; + autoComplete.activeCommand = { categories: [4, 11], label: 'Character' }; + autoComplete.items = mockTags; + autoComplete.selectedIndex = 0; + autoComplete.currentSearchTerm = '/char looking to the side'; + + await autoComplete.insertSelection('looking_to_the_side'); + + // Command part should be replaced along with search term + expect(input.value).toBe('looking_to_the_side, '); + }); + + it('replaces only last token when multi-word query does not exactly match selected tag', async () => { + const mockTags = [ + { tag_name: 'blue_hair', category: 0, post_count: 45000 }, + { tag_name: 'blue_eyes', category: 0, post_count: 80000 }, + ]; + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, words: mockTags }), + }); + + // User types "looking to the blue" but selects "blue_hair" (doesn't match entire phrase) + caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the blue'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = 'looking to the blue'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + autoComplete.searchType = 'custom_words'; + autoComplete.activeCommand = null; + autoComplete.items = mockTags; + autoComplete.selectedIndex = 0; + autoComplete.currentSearchTerm = 'looking to the blue'; + + await autoComplete.insertSelection('blue_hair'); + + // Only "blue" should be replaced, not the entire phrase + expect(input.value).toBe('looking to the blue_hair, '); + }); + + it('handles multiple consecutive spaces in multi-word phrase correctly', async () => { + const mockTags = [ + { tag_name: 'looking_to_the_side', category: 0, post_count: 1234 }, + ]; + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, words: mockTags }), + }); + + // Input with multiple spaces between words + caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the side'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = 'looking to the side'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + autoComplete.searchType = 'custom_words'; + autoComplete.activeCommand = null; + autoComplete.items = mockTags; + autoComplete.selectedIndex = 0; + autoComplete.currentSearchTerm = 'looking to the side'; + + await autoComplete.insertSelection('looking_to_the_side'); + + // Multiple spaces should be normalized to single underscores for matching + expect(input.value).toBe('looking_to_the_side, '); + }); + + it('handles command mode with partial match replacing only last token', async () => { + const mockTags = [ + { tag_name: 'blue_hair', category: 0, post_count: 45000 }, + ]; + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, words: mockTags }), + }); + + // Command mode but selected tag doesn't match entire search phrase + caretHelperInstance.getBeforeCursor.mockReturnValue('/general looking to the blue'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = '/general looking to the blue'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + // Command mode with activeCommand + autoComplete.searchType = 'custom_words'; + autoComplete.activeCommand = { categories: [0, 7], label: 'General' }; + autoComplete.items = mockTags; + autoComplete.selectedIndex = 0; + autoComplete.currentSearchTerm = '/general looking to the blue'; + + await autoComplete.insertSelection('blue_hair'); + + // In command mode, the entire command + search term should be replaced + expect(input.value).toBe('blue_hair, '); + }); + + it('replaces entire phrase when selected tag starts with underscore version of search term (prefix match)', async () => { + const mockTags = [ + { tag_name: 'looking_to_the_side', category: 0, post_count: 1234 }, + ]; + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, words: mockTags }), + }); + + // User types partial phrase "looking to the" and selects "looking_to_the_side" + caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = 'looking to the'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + autoComplete.searchType = 'custom_words'; + autoComplete.activeCommand = null; + autoComplete.items = mockTags; + autoComplete.selectedIndex = 0; + autoComplete.currentSearchTerm = 'looking to the'; + + await autoComplete.insertSelection('looking_to_the_side'); + + // Entire phrase should be replaced with selected tag (with underscores) + expect(input.value).toBe('looking_to_the_side, '); + }); + + it('inserts tag with underscores regardless of space replacement setting', async () => { + const mockTags = [ + { tag_name: 'blue_hair', category: 0, post_count: 45000 }, + ]; + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, words: mockTags }), + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('blue'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = 'blue'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + autoComplete.searchType = 'custom_words'; + autoComplete.activeCommand = null; + autoComplete.items = mockTags; + autoComplete.selectedIndex = 0; + + await autoComplete.insertSelection('blue_hair'); + + // Tag should be inserted with underscores, not spaces + expect(input.value).toBe('blue_hair, '); + }); + + it('replaces entire phrase when selected tag ends with underscore version of search term (suffix match)', async () => { + const mockTags = [ + { tag_name: 'looking_to_the_side', category: 0, post_count: 1234 }, + ]; + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, words: mockTags }), + }); + + // User types suffix "to the side" and selects "looking_to_the_side" + caretHelperInstance.getBeforeCursor.mockReturnValue('to the side'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = 'to the side'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + autoComplete.searchType = 'custom_words'; + autoComplete.activeCommand = null; + autoComplete.items = mockTags; + autoComplete.selectedIndex = 0; + autoComplete.currentSearchTerm = 'to the side'; + + await autoComplete.insertSelection('looking_to_the_side'); + + // Entire phrase should be replaced with selected tag + expect(input.value).toBe('looking_to_the_side, '); + }); }); diff --git a/tests/test_tag_fts_index.py b/tests/test_tag_fts_index.py index cb5aebb3..6784b790 100644 --- a/tests/test_tag_fts_index.py +++ b/tests/test_tag_fts_index.py @@ -242,36 +242,70 @@ class TestTagFTSIndexSearch: ) def test_search_pagination_ordering_consistency(self, populated_fts): - """Test that pagination maintains consistent ordering.""" + """Test that pagination maintains consistent ordering by post_count.""" page1 = populated_fts.search("1", limit=10, offset=0) page2 = populated_fts.search("1", limit=10, offset=10) assert len(page1) > 0, "Page 1 should have results" assert len(page2) > 0, "Page 2 should have results" - # Page 2 scores should all be <= Page 1 min score - page1_min_score = min(r["rank_score"] for r in page1) - page2_max_score = max(r["rank_score"] for r in page2) + # Page 2 max post_count should be <= Page 1 min post_count + page1_min_posts = min(r["post_count"] for r in page1) + page2_max_posts = max(r["post_count"] for r in page2) - assert page2_max_score <= page1_min_score, ( - f"Page 2 max score ({page2_max_score}) should be <= Page 1 min score ({page1_min_score})" + assert page2_max_posts <= page1_min_posts, ( + f"Page 2 max post_count ({page2_max_posts}) should be <= Page 1 min post_count ({page1_min_posts})" ) - def test_search_rank_score_includes_popularity_weight(self, populated_fts): - """Test that rank_score includes post_count popularity weighting.""" + def test_search_returns_popular_tags_higher(self, populated_fts): + """Test that search returns popular tags (higher post_count) first.""" results = populated_fts.search("1", limit=5) assert len(results) >= 2, "Need at least 2 results to compare" - # 1girl has 6M posts, should have higher rank_score than tags with fewer posts + # 1girl has 6M posts, should be ranked first girl_result = next((r for r in results if r["tag_name"] == "1girl"), None) assert girl_result is not None, "1girl should be in results" + assert results[0]["tag_name"] == "1girl", ( + "1girl should be first due to highest post_count" + ) # Find a tag with significantly fewer posts low_post_result = next((r for r in results if r["post_count"] < 10000), None) if low_post_result: - assert girl_result["rank_score"] > low_post_result["rank_score"], ( - f"1girl (6M posts) should have higher score than {low_post_result['tag_name']} ({low_post_result['post_count']} posts)" + assert girl_result["post_count"] > low_post_result["post_count"], ( + f"1girl (6M posts) should have higher post_count than {low_post_result['tag_name']} ({low_post_result['post_count']} posts)" + ) + + def test_search_popularity_ordering(self, populated_fts): + """Test that results are ordered by post_count (popularity).""" + results = populated_fts.search("1", limit=20) + + # Get 1girl and 1boy results for comparison + girl_result = next((r for r in results if r["tag_name"] == "1girl"), None) + boy_result = next((r for r in results if r["tag_name"] == "1boy"), None) + + assert girl_result is not None, "1girl should be in results" + assert boy_result is not None, "1boy should be in results" + + # 1girl: 6M posts, 1boy: 1.4M posts + assert girl_result["post_count"] == 6008644, "1girl should have 6M posts" + assert boy_result["post_count"] == 1405457, "1boy should have 1.4M posts" + + # 1girl should rank higher due to higher post_count + girl_rank = results.index(girl_result) + boy_rank = results.index(boy_result) + assert girl_rank < boy_rank, ( + f"1girl should rank higher than 1boy due to higher post_count " + f"(girl rank: {girl_rank}, boy rank: {boy_rank})" + ) + + # Verify results are sorted by post_count descending + for i in range(len(results) - 1): + assert results[i]["post_count"] >= results[i + 1]["post_count"], ( + f"Results should be sorted by post_count descending: " + f"{results[i]['tag_name']} ({results[i]['post_count']}) >= " + f"{results[i + 1]['tag_name']} ({results[i + 1]['post_count']})" ) diff --git a/web/comfyui/autocomplete.js b/web/comfyui/autocomplete.js index 421b23a5..3c7c23ce 100644 --- a/web/comfyui/autocomplete.js +++ b/web/comfyui/autocomplete.js @@ -1905,10 +1905,38 @@ class AutoComplete { // For regular tag autocomplete (no command), only replace the last space-separated token // This allows "hello 1gi" + selecting "1girl" to become "hello 1girl, " + // However, if the user typed a multi-word phrase that matches a tag (e.g., "looking to the side" + // matching "looking_to_the_side"), replace the entire phrase instead of just the last word. // Command mode (e.g., "/char miku") should replace the entire command+search let searchTerm = fullSearchTerm; if (this.modelType === 'prompt' && this.searchType === 'custom_words' && !this.activeCommand) { - searchTerm = this._getLastSpaceToken(fullSearchTerm); + // Check if the selectedItem exists and its tag_name matches the full search term + // when converted to underscore format (Danbooru convention) + const selectedItem = this.selectedIndex >= 0 ? this.items[this.selectedIndex] : null; + const selectedTagName = selectedItem && typeof selectedItem === 'object' && 'tag_name' + ? selectedItem.tag_name + : null; + + // Convert full search term to underscore format and check if it matches selected tag + // Normalize multiple spaces to single underscore for matching (e.g., "looking to the side" -> "looking_to_the_side") + const underscoreVersion = fullSearchTerm.replace(/ +/g, '_').toLowerCase(); + const selectedTagLower = selectedTagName?.toLowerCase() ?? ''; + + // If multi-word search term is a prefix or suffix of the selected tag, + // replace the entire phrase. This handles cases where user types partial tag name. + // Examples: + // - "looking to the" -> "looking_to_the_side" (prefix match) + // - "to the side" -> "looking_to_the_side" (suffix match) + // - "looking to the side" -> "looking_to_the_side" (exact match) + if (fullSearchTerm.includes(' ') && ( + selectedTagLower.startsWith(underscoreVersion) || + selectedTagLower.endsWith(underscoreVersion) || + underscoreVersion === selectedTagLower + )) { + searchTerm = fullSearchTerm; + } else { + searchTerm = this._getLastSpaceToken(fullSearchTerm); + } } const searchStartPos = caretPos - searchTerm.length;