From 0bcd8e09a9faad57dc28f08f36ce897f82fd2811 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Fri, 17 Apr 2026 20:27:48 +0800 Subject: [PATCH] fix(filters): improve base model filtering UX --- locales/de.json | 2 + locales/en.json | 2 + locales/es.json | 2 + locales/fr.json | 2 + locales/he.json | 2 + locales/ja.json | 2 + locales/ko.json | 2 + locales/ru.json | 2 + locales/zh-CN.json | 2 + locales/zh-TW.json | 2 + py/routes/handlers/model_handlers.py | 2 +- py/routes/handlers/recipe_handlers.py | 3 + py/services/model_scanner.py | 6 +- static/css/components/search-filter.css | 29 ++- static/js/managers/BulkManager.js | 24 ++- static/js/managers/FilterManager.js | 116 +++++++----- static/js/utils/eventManagementInit.js | 13 ++ templates/components/header.html | 15 +- .../components/pageControls.filtering.test.js | 165 ++++++++++++++++++ tests/routes/test_model_query_handler.py | 38 ++++ tests/routes/test_recipe_query_handler.py | 44 +++++ .../test_model_scanner_base_models.py | 52 ++++++ 22 files changed, 479 insertions(+), 48 deletions(-) create mode 100644 tests/routes/test_model_query_handler.py create mode 100644 tests/routes/test_recipe_query_handler.py create mode 100644 tests/services/test_model_scanner_base_models.py diff --git a/locales/de.json b/locales/de.json index 79b2de11..8584a12d 100644 --- a/locales/de.json +++ b/locales/de.json @@ -225,12 +225,14 @@ "presetOverwriteConfirm": "Voreinstellung \"{name}\" existiert bereits. Überschreiben?", "presetNamePlaceholder": "Voreinstellungsname...", "baseModel": "Basis-Modell", + "baseModelSearchPlaceholder": "Basismodelle durchsuchen...", "modelTags": "Tags (Top 20)", "modelTypes": "Modelltypen", "license": "Lizenz", "noCreditRequired": "Kein Credit erforderlich", "allowSellingGeneratedContent": "Verkauf erlaubt", "noTags": "Keine Tags", + "noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.", "clearAll": "Alle Filter löschen", "any": "Beliebig", "all": "Alle", diff --git a/locales/en.json b/locales/en.json index c2382e5a..e4272a48 100644 --- a/locales/en.json +++ b/locales/en.json @@ -225,12 +225,14 @@ "presetOverwriteConfirm": "Preset \"{name}\" already exists. Overwrite?", "presetNamePlaceholder": "Preset name...", "baseModel": "Base Model", + "baseModelSearchPlaceholder": "Search base models...", "modelTags": "Tags (Top 20)", "modelTypes": "Model Types", "license": "License", "noCreditRequired": "No Credit Required", "allowSellingGeneratedContent": "Allow Selling", "noTags": "No tags", + "noBaseModelMatches": "No base models match the current search.", "clearAll": "Clear All Filters", "any": "Any", "all": "All", diff --git a/locales/es.json b/locales/es.json index a49b704b..45ba1491 100644 --- a/locales/es.json +++ b/locales/es.json @@ -225,12 +225,14 @@ "presetOverwriteConfirm": "El preset \"{name}\" ya existe. ¿Sobrescribir?", "presetNamePlaceholder": "Nombre del preajuste...", "baseModel": "Modelo base", + "baseModelSearchPlaceholder": "Buscar modelos base...", "modelTags": "Etiquetas (Top 20)", "modelTypes": "Tipos de modelos", "license": "Licencia", "noCreditRequired": "Sin crédito requerido", "allowSellingGeneratedContent": "Venta permitida", "noTags": "Sin etiquetas", + "noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.", "clearAll": "Limpiar todos los filtros", "any": "Cualquiera", "all": "Todos", diff --git a/locales/fr.json b/locales/fr.json index d809f4bb..15ac157c 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -225,12 +225,14 @@ "presetOverwriteConfirm": "Le préréglage \"{name}\" existe déjà. Remplacer?", "presetNamePlaceholder": "Nom du préréglage...", "baseModel": "Modèle de base", + "baseModelSearchPlaceholder": "Rechercher des modèles de base...", "modelTags": "Tags (Top 20)", "modelTypes": "Types de modèles", "license": "Licence", "noCreditRequired": "Crédit non requis", "allowSellingGeneratedContent": "Vente autorisée", "noTags": "Aucun tag", + "noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.", "clearAll": "Effacer tous les filtres", "any": "N'importe quel", "all": "Tous", diff --git a/locales/he.json b/locales/he.json index 94a7131f..8dfdab61 100644 --- a/locales/he.json +++ b/locales/he.json @@ -225,12 +225,14 @@ "presetOverwriteConfirm": "הפריסט \"{name}\" כבר קיים. לדרוס?", "presetNamePlaceholder": "שם קביעה מראש...", "baseModel": "מודל בסיס", + "baseModelSearchPlaceholder": "חפש מודלי בסיס...", "modelTags": "תגיות (20 המובילות)", "modelTypes": "סוגי מודלים", "license": "רישיון", "noCreditRequired": "ללא קרדיט נדרש", "allowSellingGeneratedContent": "אפשר מכירה", "noTags": "ללא תגיות", + "noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.", "clearAll": "נקה את כל המסננים", "any": "כלשהו", "all": "כל התגים", diff --git a/locales/ja.json b/locales/ja.json index d5a04e21..e1d4448d 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -225,12 +225,14 @@ "presetOverwriteConfirm": "プリセット「{name}」は既に存在します。上書きしますか?", "presetNamePlaceholder": "プリセット名...", "baseModel": "ベースモデル", + "baseModelSearchPlaceholder": "ベースモデルを検索...", "modelTags": "タグ(上位20)", "modelTypes": "モデルタイプ", "license": "ライセンス", "noCreditRequired": "クレジット不要", "allowSellingGeneratedContent": "販売許可", "noTags": "タグなし", + "noBaseModelMatches": "現在の検索に一致するベースモデルはありません。", "clearAll": "すべてのフィルタをクリア", "any": "いずれか", "all": "すべて", diff --git a/locales/ko.json b/locales/ko.json index 15bfda7b..5a3f2ec0 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -225,12 +225,14 @@ "presetOverwriteConfirm": "프리셋 \"{name}\"이(가) 이미 존재합니다. 덮어쓰시겠습니까?", "presetNamePlaceholder": "프리셋 이름...", "baseModel": "베이스 모델", + "baseModelSearchPlaceholder": "베이스 모델 검색...", "modelTags": "태그 (상위 20개)", "modelTypes": "모델 유형", "license": "라이선스", "noCreditRequired": "크레딧 표기 없음", "allowSellingGeneratedContent": "판매 허용", "noTags": "태그 없음", + "noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.", "clearAll": "모든 필터 지우기", "any": "아무", "all": "모두", diff --git a/locales/ru.json b/locales/ru.json index 966993d8..cf0612fe 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -225,12 +225,14 @@ "presetOverwriteConfirm": "Пресет \"{name}\" уже существует. Перезаписать?", "presetNamePlaceholder": "Имя пресета...", "baseModel": "Базовая модель", + "baseModelSearchPlaceholder": "Поиск базовых моделей...", "modelTags": "Теги (Топ 20)", "modelTypes": "Типы моделей", "license": "Лицензия", "noCreditRequired": "Без указания авторства", "allowSellingGeneratedContent": "Продажа разрешена", "noTags": "Без тегов", + "noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.", "clearAll": "Очистить все фильтры", "any": "Любой", "all": "Все", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index b8a79922..898f550a 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -225,12 +225,14 @@ "presetOverwriteConfirm": "预设 \"{name}\" 已存在。是否覆盖?", "presetNamePlaceholder": "预设名称...", "baseModel": "基础模型", + "baseModelSearchPlaceholder": "搜索基础模型...", "modelTags": "标签(前20)", "modelTypes": "模型类型", "license": "许可证", "noCreditRequired": "无需署名", "allowSellingGeneratedContent": "允许销售", "noTags": "无标签", + "noBaseModelMatches": "没有基础模型符合当前搜索。", "clearAll": "清除所有筛选", "any": "任一", "all": "全部", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index b75e68d1..ef4077ba 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -225,12 +225,14 @@ "presetOverwriteConfirm": "預設 \"{name}\" 已存在。是否覆蓋?", "presetNamePlaceholder": "預設名稱...", "baseModel": "基礎模型", + "baseModelSearchPlaceholder": "搜尋基礎模型...", "modelTags": "標籤(前 20)", "modelTypes": "模型類型", "license": "授權", "noCreditRequired": "無需署名", "allowSellingGeneratedContent": "允許銷售", "noTags": "無標籤", + "noBaseModelMatches": "沒有基礎模型符合目前的搜尋。", "clearAll": "清除所有篩選", "any": "任一", "all": "全部", diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index c5b2babd..18bb5e1b 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -910,7 +910,7 @@ class ModelQueryHandler: async def get_base_models(self, request: web.Request) -> web.Response: try: limit = int(request.query.get("limit", "20")) - if limit < 1 or limit > 100: + if limit < 0 or limit > 100: limit = 20 base_models = await self._service.get_base_models(limit) return web.json_response({"success": True, "base_models": base_models}) diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index 5b477115..40f703a3 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -329,6 +329,7 @@ class RecipeQueryHandler: if recipe_scanner is None: raise RuntimeError("Recipe scanner unavailable") + limit = int(request.query.get("limit", "20")) cache = await recipe_scanner.get_cached_data() base_model_counts: Dict[str, int] = {} @@ -344,6 +345,8 @@ class RecipeQueryHandler: for model, count in base_model_counts.items() ] sorted_models.sort(key=lambda entry: entry["count"], reverse=True) + if limit > 0: + sorted_models = sorted_models[:limit] return web.json_response({"success": True, "base_models": sorted_models}) except Exception as exc: self._logger.error("Error retrieving base models: %s", exc, exc_info=True) diff --git a/py/services/model_scanner.py b/py/services/model_scanner.py index a364214c..8330c4b9 100644 --- a/py/services/model_scanner.py +++ b/py/services/model_scanner.py @@ -1535,7 +1535,7 @@ class ModelScanner: return sorted_tags[:limit] async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]: - """Get base models sorted by frequency""" + """Get base models sorted by count. If limit is 0, return all.""" cache = await self.get_cached_data() base_model_counts = {} @@ -1546,7 +1546,9 @@ class ModelScanner: sorted_models = [{'name': model, 'count': count} for model, count in base_model_counts.items()] sorted_models.sort(key=lambda x: x['count'], reverse=True) - + + if limit == 0: + return sorted_models return sorted_models[:limit] async def get_model_info_by_name(self, name): diff --git a/static/css/components/search-filter.css b/static/css/components/search-filter.css index b5ddee4a..beed5b74 100644 --- a/static/css/components/search-filter.css +++ b/static/css/components/search-filter.css @@ -145,7 +145,7 @@ position: fixed; right: 20px; top: 50px; /* Position below header */ - width: 320px; + width: 366px; background-color: var(--card-bg); border: 1px solid var(--border-color); border-radius: var(--border-radius-base); @@ -197,6 +197,31 @@ margin-bottom: 16px; } +.filter-search-input { + width: 100%; + box-sizing: border-box; + margin-bottom: 8px; + padding: 8px 10px; + border-radius: var(--border-radius-sm); + border: 1px solid var(--border-color); + background-color: var(--lora-surface); + color: var(--text-color); + font-size: 13px; +} + +.filter-search-input:focus { + outline: none; + border-color: var(--lora-accent); + box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 76, 175, 80), 0.15); +} + +.filter-empty-state { + margin-top: 8px; + font-size: 13px; + color: var(--text-color); + opacity: 0.7; +} + .filter-section h4 { margin: 0 0 8px 0; font-size: 14px; @@ -733,4 +758,4 @@ right: 20px; top: 160px; /* Adjusted for mobile layout */ } -} \ No newline at end of file +} diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index cebe057b..fa44914d 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -240,9 +240,7 @@ export class BulkManager { */ handleGlobalKeyboard(e) { // Skip if modal is open (handled by event manager conditions) - // Skip if search input is focused - const searchInput = document.getElementById('searchInput'); - if (searchInput && document.activeElement === searchInput) { + if (this.isEditingTextInputContext(e.target)) { return false; // Don't handle, allow default behavior } @@ -266,6 +264,26 @@ export class BulkManager { return false; // Continue with other handlers } + isEditingTextInputContext(target) { + const activeElement = document.activeElement; + const candidate = target instanceof Element ? target : activeElement; + if (!candidate) { + return false; + } + + const tagName = candidate.tagName?.toLowerCase(); + if ( + candidate.isContentEditable + || tagName === 'input' + || tagName === 'textarea' + || tagName === 'select' + ) { + return true; + } + + return Boolean(candidate.closest?.('#filterPanel')); + } + toggleBulkMode() { state.bulkMode = !state.bulkMode; diff --git a/static/js/managers/FilterManager.js b/static/js/managers/FilterManager.js index 1b62eab1..1b8956c8 100644 --- a/static/js/managers/FilterManager.js +++ b/static/js/managers/FilterManager.js @@ -20,6 +20,8 @@ export class FilterManager { this.filterPanel = document.getElementById('filterPanel'); this.filterButton = document.getElementById('filterButton'); this.activeFiltersCount = document.getElementById('activeFiltersCount'); + this.baseModelSearchInput = document.getElementById('baseModelSearchInput'); + this.baseModelOptions = []; this.tagsLoaded = false; // Initialize preset manager @@ -49,6 +51,8 @@ export class FilterManager { } initialize() { + this.initializeFilterSearchInputs(); + // Create base model filter tags if they exist if (document.getElementById('baseModelTags')) { this.createBaseModelTags(); @@ -110,6 +114,18 @@ export class FilterManager { this.updateTagLogicToggleUI(); } + initializeFilterSearchInputs() { + if (this.baseModelSearchInput) { + this.baseModelSearchInput.addEventListener('input', () => { + this.renderBaseModelTags(); + }); + } + } + + getNormalizedSearchQuery(input) { + return (input?.value || '').trim().toLowerCase(); + } + updateTagLogicToggleUI() { const toggleContainer = document.getElementById('tagLogicToggle'); if (!toggleContainer) return; @@ -164,11 +180,6 @@ export class FilterManager { tagsContainer.innerHTML = ''; - if (!tags.length) { - tagsContainer.innerHTML = `
No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available
`; - return; - } - // Collect existing tag names from the API response const existingTagNames = new Set(tags.map(t => t.tag)); @@ -186,6 +197,11 @@ export class FilterManager { }); } + if (!tags.length) { + tagsContainer.innerHTML = `
No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available
`; + return; + } + tags.forEach(tag => { const tagEl = document.createElement('div'); tagEl.className = 'filter-tag tag-filter'; @@ -212,7 +228,6 @@ export class FilterManager { await this.applyFilters(false); }); - this.applyTagElementState(tagEl, (this.filters.tags && this.filters.tags[tagName]) || 'none'); tagsContainer.appendChild(tagEl); }); @@ -235,8 +250,8 @@ export class FilterManager { await this.applyFilters(false); }); - this.applyTagElementState(noTagsEl, (this.filters.tags && this.filters.tags[noTagsKey]) || 'none'); tagsContainer.appendChild(noTagsEl); + this.updateTagSelections(); } initializeLicenseFilters() { @@ -323,44 +338,15 @@ export class FilterManager { if (!baseModelTagsContainer) return; // Set the API endpoint based on current page - const apiEndpoint = `/api/lm/${this.currentPage}/base-models`; + const apiEndpoint = `/api/lm/${this.currentPage}/base-models?limit=0`; // Fetch base models fetch(apiEndpoint) .then(response => response.json()) .then(data => { if (data.success && data.base_models) { - baseModelTagsContainer.innerHTML = ''; - - data.base_models.forEach(model => { - const tag = document.createElement('div'); - tag.className = `filter-tag base-model-tag`; - tag.dataset.baseModel = model.name; - tag.innerHTML = `${model.name} ${model.count}`; - - // Add click handler to toggle selection and automatically apply - tag.addEventListener('click', async () => { - tag.classList.toggle('active'); - - if (tag.classList.contains('active')) { - if (!this.filters.baseModel.includes(model.name)) { - this.filters.baseModel.push(model.name); - } - } else { - this.filters.baseModel = this.filters.baseModel.filter(m => m !== model.name); - } - - this.updateActiveFiltersCount(); - - // Auto-apply filter when tag is clicked - await this.applyFilters(false); - }); - - baseModelTagsContainer.appendChild(tag); - }); - - // Update selections based on stored filters - this.updateTagSelections(); + this.baseModelOptions = data.base_models; + this.renderBaseModelTags(); } }) .catch(error => { @@ -369,6 +355,57 @@ export class FilterManager { }); } + renderBaseModelTags() { + const baseModelTagsContainer = document.getElementById('baseModelTags'); + const emptyState = document.getElementById('baseModelEmptyState'); + if (!baseModelTagsContainer) return; + + baseModelTagsContainer.innerHTML = ''; + + if (!this.baseModelOptions.length) { + baseModelTagsContainer.innerHTML = '
No base models available
'; + if (emptyState) { + emptyState.hidden = true; + } + return; + } + + const query = this.getNormalizedSearchQuery(this.baseModelSearchInput); + const filteredModels = query + ? this.baseModelOptions.filter(model => model.name.toLowerCase().includes(query)) + : this.baseModelOptions; + + filteredModels.forEach(model => { + const tag = document.createElement('div'); + tag.className = 'filter-tag base-model-tag'; + tag.dataset.baseModel = model.name; + tag.innerHTML = `${model.name} ${model.count}`; + + tag.addEventListener('click', async () => { + tag.classList.toggle('active'); + + if (tag.classList.contains('active')) { + if (!this.filters.baseModel.includes(model.name)) { + this.filters.baseModel.push(model.name); + } + } else { + this.filters.baseModel = this.filters.baseModel.filter(m => m !== model.name); + } + + this.updateActiveFiltersCount(); + await this.applyFilters(false); + }); + + baseModelTagsContainer.appendChild(tag); + }); + + if (emptyState) { + emptyState.hidden = filteredModels.length > 0; + } + + this.updateTagSelections(); + } + async createModelTypeTags() { const modelTypeContainer = document.getElementById('modelTypeTags'); if (!modelTypeContainer) return; @@ -453,6 +490,7 @@ export class FilterManager { this.filterPanel.classList.remove('hidden'); this.filterButton.classList.add('active'); + this.baseModelSearchInput?.focus(); // Load tags if they haven't been loaded yet if (!this.tagsLoaded) { diff --git a/static/js/utils/eventManagementInit.js b/static/js/utils/eventManagementInit.js index 98055f43..e42cd484 100644 --- a/static/js/utils/eventManagementInit.js +++ b/static/js/utils/eventManagementInit.js @@ -25,6 +25,7 @@ export function initializeEventManagement() { setupPageUnloadCleanup(); // Register global event handlers that need coordination + registerGlobalEventHandlers(); registerContextMenuEvents(); registerGlobalClickHandlers(); @@ -148,6 +149,10 @@ function registerGlobalClickHandlers() { * Register common application-wide event handlers */ export function registerGlobalEventHandlers() { + eventManager.removeHandler('keydown', 'global-escape'); + eventManager.removeHandler('focusin', 'global-focus'); + eventManager.removeHandler('click', 'global-analytics'); + // Escape key handler for closing modals/panels eventManager.addHandler('keydown', 'global-escape', (e) => { if (e.key === 'Escape') { @@ -156,6 +161,14 @@ export function registerGlobalEventHandlers() { modalManager.closeCurrentModal(); return true; // Stop propagation } + + if ( + window.filterManager?.filterPanel + && !window.filterManager.filterPanel.classList.contains('hidden') + ) { + window.filterManager.closeFilterPanel(); + return true; // Stop propagation + } // Check if node selector is active and close it if (eventManager.getState('nodeSelectorActive')) { diff --git a/templates/components/header.html b/templates/components/header.html index b5494b70..0725c923 100644 --- a/templates/components/header.html +++ b/templates/components/header.html @@ -145,9 +145,22 @@

{{ t('header.filter.baseModel') }}

+
+
@@ -188,4 +201,4 @@ {{ t('header.filter.clearAll') }}
-
\ No newline at end of file + diff --git a/tests/frontend/components/pageControls.filtering.test.js b/tests/frontend/components/pageControls.filtering.test.js index 71eeda63..cebcf791 100644 --- a/tests/frontend/components/pageControls.filtering.test.js +++ b/tests/frontend/components/pageControls.filtering.test.js @@ -110,7 +110,9 @@ function renderControlsDom(pageKey) {
@@ -286,6 +288,8 @@ describe('FilterManager tag and base model filters', () => { const manager = new FilterManager({ page: pageKey }); + expect(global.fetch).toHaveBeenCalledWith(`/api/lm/${pageKey}/base-models?limit=0`); + await vi.waitFor(() => { const chip = document.querySelector('[data-base-model="SDXL"]'); expect(chip).not.toBeNull(); @@ -311,6 +315,167 @@ describe('FilterManager tag and base model filters', () => { expect(getCurrentPageState().filters.baseModel).toEqual([]); expect(baseModelChip.classList.contains('active')).toBe(false); }); + + it('filters base model chips locally without changing selected state', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + base_models: [ + { name: 'SDXL', count: 2 }, + { name: 'LTXV 2.3', count: 1 }, + ], + }), + }); + + renderControlsDom('loras'); + const stateModule = await import('../../../static/js/state/index.js'); + stateModule.initPageState('loras'); + const { getCurrentPageState } = stateModule; + const { FilterManager } = await import('../../../static/js/managers/FilterManager.js'); + + new FilterManager({ page: 'loras' }); + + await vi.waitFor(() => { + expect(document.querySelector('[data-base-model="LTXV 2.3"]')).not.toBeNull(); + }); + + const searchInput = document.getElementById('baseModelSearchInput'); + const ltxvChip = document.querySelector('[data-base-model="LTXV 2.3"]'); + ltxvChip.dispatchEvent(new Event('click', { bubbles: true })); + await vi.waitFor(() => expect(loadMoreWithVirtualScrollMock).toHaveBeenCalledTimes(1)); + expect(getCurrentPageState().filters.baseModel).toEqual(['LTXV 2.3']); + + loadMoreWithVirtualScrollMock.mockClear(); + searchInput.value = 'sdx'; + searchInput.dispatchEvent(new Event('input', { bubbles: true })); + + expect(document.querySelector('[data-base-model="SDXL"]')).not.toBeNull(); + expect(document.querySelector('[data-base-model="LTXV 2.3"]')).toBeNull(); + expect(document.getElementById('baseModelEmptyState').hidden).toBe(true); + expect(getCurrentPageState().filters.baseModel).toEqual(['LTXV 2.3']); + + searchInput.value = 'zzz'; + searchInput.dispatchEvent(new Event('input', { bubbles: true })); + expect(document.getElementById('baseModelEmptyState').hidden).toBe(false); + + searchInput.value = 'ltx'; + searchInput.dispatchEvent(new Event('input', { bubbles: true })); + const restoredChip = document.querySelector('[data-base-model="LTXV 2.3"]'); + expect(restoredChip).not.toBeNull(); + expect(restoredChip.classList.contains('active')).toBe(true); + }); + + it('disables browser autocomplete helpers for the base model search input', async () => { + renderControlsDom('loras'); + + const searchInput = document.getElementById('baseModelSearchInput'); + + searchInput.setAttribute('autocomplete', 'off'); + searchInput.setAttribute('autocorrect', 'off'); + searchInput.setAttribute('autocapitalize', 'none'); + searchInput.setAttribute('spellcheck', 'false'); + + expect(searchInput.getAttribute('autocomplete')).toBe('off'); + expect(searchInput.getAttribute('autocorrect')).toBe('off'); + expect(searchInput.getAttribute('autocapitalize')).toBe('none'); + expect(searchInput.getAttribute('spellcheck')).toBe('false'); + }); + + it('focuses the base model search input when opening the filter panel', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + base_models: [{ name: 'SDXL', count: 2 }], + }), + }); + + renderControlsDom('loras'); + const stateModule = await import('../../../static/js/state/index.js'); + stateModule.initPageState('loras'); + const { FilterManager } = await import('../../../static/js/managers/FilterManager.js'); + + const manager = new FilterManager({ page: 'loras' }); + const searchInput = document.getElementById('baseModelSearchInput'); + + expect(document.activeElement).not.toBe(searchInput); + + manager.toggleFilterPanel(); + + expect(document.activeElement).toBe(searchInput); + }); + + it('does not let base model search trigger bulk shortcuts', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + base_models: [{ name: 'SDXL', count: 2 }], + }), + }); + + renderControlsDom('loras'); + const stateModule = await import('../../../static/js/state/index.js'); + stateModule.initPageState('loras'); + const { BulkManager } = await import('../../../static/js/managers/BulkManager.js'); + const { FilterManager } = await import('../../../static/js/managers/FilterManager.js'); + + const filterManager = new FilterManager({ page: 'loras' }); + const bulkManager = new BulkManager(); + const searchInput = document.getElementById('baseModelSearchInput'); + window.filterManager = filterManager; + + searchInput.focus(); + + const bulkEvent = new KeyboardEvent('keydown', { + key: 'b', + bubbles: true, + cancelable: true, + }); + Object.defineProperty(bulkEvent, 'target', { value: searchInput }); + expect(bulkManager.handleGlobalKeyboard(bulkEvent)).toBe(false); + + const selectAllEvent = new KeyboardEvent('keydown', { + key: 'a', + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + Object.defineProperty(selectAllEvent, 'target', { value: searchInput }); + expect(bulkManager.handleGlobalKeyboard(selectAllEvent)).toBe(false); + }); + + it('closes the filter panel on Escape', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + base_models: [{ name: 'SDXL', count: 2 }], + }), + }); + + renderControlsDom('loras'); + const stateModule = await import('../../../static/js/state/index.js'); + stateModule.initPageState('loras'); + const { FilterManager } = await import('../../../static/js/managers/FilterManager.js'); + const { eventManager } = await import('../../../static/js/utils/EventManager.js'); + const { initializeEventManagement } = await import('../../../static/js/utils/eventManagementInit.js'); + + eventManager.cleanup(); + initializeEventManagement(); + + const manager = new FilterManager({ page: 'loras' }); + window.filterManager = manager; + manager.toggleFilterPanel(); + expect(manager.filterPanel.classList.contains('hidden')).toBe(false); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + expect(manager.filterPanel.classList.contains('hidden')).toBe(true); + eventManager.cleanup(); + }); + }); describe('PageControls favorites, sorting, and duplicates scenarios', () => { diff --git a/tests/routes/test_model_query_handler.py b/tests/routes/test_model_query_handler.py new file mode 100644 index 00000000..11278528 --- /dev/null +++ b/tests/routes/test_model_query_handler.py @@ -0,0 +1,38 @@ +import json +import logging +from types import SimpleNamespace + +import pytest + +from py.routes.handlers.model_handlers import ModelQueryHandler + + +class DummyService: + def __init__(self): + self.received_limit = None + + async def get_base_models(self, limit): + self.received_limit = limit + return [{"name": "SDXL", "count": 2}] + + +@pytest.mark.asyncio +async def test_model_query_handler_accepts_limit_zero_for_base_models(): + service = DummyService() + handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__)) + + response = await handler.get_base_models(SimpleNamespace(query={"limit": "0"})) + payload = json.loads(response.text) + + assert payload["success"] is True + assert service.received_limit == 0 + + +@pytest.mark.asyncio +async def test_model_query_handler_rejects_negative_limit_for_base_models(): + service = DummyService() + handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__)) + + await handler.get_base_models(SimpleNamespace(query={"limit": "-1"})) + + assert service.received_limit == 20 diff --git a/tests/routes/test_recipe_query_handler.py b/tests/routes/test_recipe_query_handler.py new file mode 100644 index 00000000..774c1033 --- /dev/null +++ b/tests/routes/test_recipe_query_handler.py @@ -0,0 +1,44 @@ +import json +import logging +from types import SimpleNamespace + +import pytest + +from py.routes.handlers.recipe_handlers import RecipeQueryHandler + + +async def _noop(): + return None + + +@pytest.mark.asyncio +async def test_recipe_query_handler_base_models_limit_zero_returns_all(): + cache = SimpleNamespace( + raw_data=[ + {"base_model": "SDXL"}, + {"base_model": "LTXV 2.3"}, + {"base_model": "SDXL"}, + ] + ) + scanner = SimpleNamespace(get_cached_data=lambda: None) + + async def get_cached_data(): + return cache + + scanner.get_cached_data = get_cached_data + + handler = RecipeQueryHandler( + ensure_dependencies_ready=_noop, + recipe_scanner_getter=lambda: scanner, + format_recipe_file_url=lambda value: value, + logger=logging.getLogger(__name__), + ) + + response = await handler.get_base_models(SimpleNamespace(query={"limit": "0"})) + payload = json.loads(response.text) + + assert payload["success"] is True + assert payload["base_models"] == [ + {"name": "SDXL", "count": 2}, + {"name": "LTXV 2.3", "count": 1}, + ] diff --git a/tests/services/test_model_scanner_base_models.py b/tests/services/test_model_scanner_base_models.py new file mode 100644 index 00000000..4d1b3f3a --- /dev/null +++ b/tests/services/test_model_scanner_base_models.py @@ -0,0 +1,52 @@ +from types import SimpleNamespace + +import pytest + +from py.services.model_scanner import ModelScanner + + +class DummyScanner: + def __init__(self, raw_data): + self._cache = SimpleNamespace(raw_data=raw_data) + + async def get_cached_data(self): + return self._cache + + +@pytest.mark.asyncio +async def test_get_base_models_limit_zero_returns_all_sorted(): + scanner = DummyScanner( + [ + {"base_model": "SDXL"}, + {"base_model": "LTXV 2.3"}, + {"base_model": "SDXL"}, + {"base_model": ""}, + {}, + ] + ) + + result = await ModelScanner.get_base_models(scanner, limit=0) + + assert result == [ + {"name": "SDXL", "count": 2}, + {"name": "LTXV 2.3", "count": 1}, + ] + + +@pytest.mark.asyncio +async def test_get_base_models_positive_limit_still_truncates(): + scanner = DummyScanner( + [ + {"base_model": "SDXL"}, + {"base_model": "LTXV 2.3"}, + {"base_model": "Flux.1 D"}, + {"base_model": "SDXL"}, + ] + ) + + result = await ModelScanner.get_base_models(scanner, limit=2) + + assert result == [ + {"name": "SDXL", "count": 2}, + {"name": "LTXV 2.3", "count": 1}, + ]