diff --git a/locales/de.json b/locales/de.json index 03392548..a4963a3b 100644 --- a/locales/de.json +++ b/locales/de.json @@ -260,7 +260,11 @@ "any": "Beliebig", "all": "Alle", "tagLogicAny": "Jedes Tag abgleichen (ODER)", - "tagLogicAll": "Alle Tags abgleichen (UND)" + "tagLogicAll": "Alle Tags abgleichen (UND)", + "loraAvailability": "LoRA-Verfügbarkeit", + "availabilityReady": "Einsatzbereit", + "availabilityMissing": "Mit fehlenden LoRAs", + "availabilityDeleted": "Mit gelöschten LoRAs" }, "theme": { "toggle": "Theme wechseln", diff --git a/locales/en.json b/locales/en.json index 98842270..2e2eff37 100644 --- a/locales/en.json +++ b/locales/en.json @@ -260,7 +260,11 @@ "any": "Any", "all": "All", "tagLogicAny": "Match any tag (OR)", - "tagLogicAll": "Match all tags (AND)" + "tagLogicAll": "Match all tags (AND)", + "loraAvailability": "Lora Availability", + "availabilityReady": "Ready to use", + "availabilityMissing": "Has missing", + "availabilityDeleted": "Has deleted" }, "theme": { "toggle": "Toggle theme", diff --git a/locales/es.json b/locales/es.json index d5bc973c..8c7ca0f7 100644 --- a/locales/es.json +++ b/locales/es.json @@ -260,7 +260,11 @@ "any": "Cualquiera", "all": "Todos", "tagLogicAny": "Coincidir con cualquier etiqueta (O)", - "tagLogicAll": "Coincidir con todas las etiquetas (Y)" + "tagLogicAll": "Coincidir con todas las etiquetas (Y)", + "loraAvailability": "Disponibilidad de LoRAs", + "availabilityReady": "Listos para usar", + "availabilityMissing": "Con LoRAs faltantes", + "availabilityDeleted": "Con LoRAs eliminados" }, "theme": { "toggle": "Cambiar tema", diff --git a/locales/fr.json b/locales/fr.json index daa10e16..a88a7570 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -260,7 +260,11 @@ "any": "N'importe quel", "all": "Tous", "tagLogicAny": "Correspondre à n'importe quel tag (OU)", - "tagLogicAll": "Correspondre à tous les tags (ET)" + "tagLogicAll": "Correspondre à tous les tags (ET)", + "loraAvailability": "Disponibilité des LoRAs", + "availabilityReady": "Prêts à l'emploi", + "availabilityMissing": "Avec LoRAs manquants", + "availabilityDeleted": "Avec LoRAs supprimés" }, "theme": { "toggle": "Basculer le thème", diff --git a/locales/he.json b/locales/he.json index fb938242..df596458 100644 --- a/locales/he.json +++ b/locales/he.json @@ -260,7 +260,11 @@ "any": "כלשהו", "all": "כל התגים", "tagLogicAny": "התאם כל תג (או)", - "tagLogicAll": "התאם את כל התגים (וגם)" + "tagLogicAll": "התאם את כל התגים (וגם)", + "loraAvailability": "זמינות LoRA", + "availabilityReady": "מוכנים לשימוש", + "availabilityMissing": "עם LoRAs חסרים", + "availabilityDeleted": "עם LoRAs שנמחקו" }, "theme": { "toggle": "החלף ערכת נושא", diff --git a/locales/ja.json b/locales/ja.json index 2351c5af..7f5bf71c 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -260,7 +260,11 @@ "any": "いずれか", "all": "すべて", "tagLogicAny": "いずれかのタグに一致 (OR)", - "tagLogicAll": "すべてのタグに一致 (AND)" + "tagLogicAll": "すべてのタグに一致 (AND)", + "loraAvailability": "LoRA の利用状況", + "availabilityReady": "使用可能", + "availabilityMissing": "不足 LoRA あり", + "availabilityDeleted": "削除済み LoRA あり" }, "theme": { "toggle": "テーマの切り替え", diff --git a/locales/ko.json b/locales/ko.json index afbb25f8..343c52ee 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -260,7 +260,11 @@ "any": "아무", "all": "모두", "tagLogicAny": "모든 태그 일치 (OR)", - "tagLogicAll": "모든 태그 일치 (AND)" + "tagLogicAll": "모든 태그 일치 (AND)", + "loraAvailability": "LoRA 가용성", + "availabilityReady": "바로 사용 가능", + "availabilityMissing": "누락된 LoRA 있음", + "availabilityDeleted": "삭제된 LoRA 있음" }, "theme": { "toggle": "테마 토글", diff --git a/locales/ru.json b/locales/ru.json index fa48c048..0964f34d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -260,7 +260,11 @@ "any": "Любой", "all": "Все", "tagLogicAny": "Совпадение с любым тегом (ИЛИ)", - "tagLogicAll": "Совпадение со всеми тегами (И)" + "tagLogicAll": "Совпадение со всеми тегами (И)", + "loraAvailability": "Доступность LoRAs", + "availabilityReady": "Готовы к использованию", + "availabilityMissing": "Есть отсутствующие", + "availabilityDeleted": "Есть удалённые" }, "theme": { "toggle": "Переключить тему", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 56b44066..7be918bf 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -260,7 +260,11 @@ "any": "任一", "all": "全部", "tagLogicAny": "匹配任一标签 (或)", - "tagLogicAll": "匹配所有标签 (与)" + "tagLogicAll": "匹配所有标签 (与)", + "loraAvailability": "LoRA 可用性", + "availabilityReady": "可直接使用", + "availabilityMissing": "包含缺失 LoRA", + "availabilityDeleted": "包含已删除 LoRA" }, "theme": { "toggle": "切换主题", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index f57c482a..e9e4a779 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -260,7 +260,11 @@ "any": "任一", "all": "全部", "tagLogicAny": "符合任一票籤 (或)", - "tagLogicAll": "符合所有標籤 (與)" + "tagLogicAll": "符合所有標籤 (與)", + "loraAvailability": "LoRA 可用性", + "availabilityReady": "可直接使用", + "availabilityMissing": "包含缺少的 LoRA", + "availabilityDeleted": "包含已刪除的 LoRA" }, "theme": { "toggle": "切換主題", diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index af04a0e1..8f8ba243 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -274,6 +274,14 @@ class RecipeListingHandler: if tag_filters: filters["tags"] = tag_filters + lora_availability = { + status.strip() + for status in request.query.get("lora_availability", "").split(",") + if status.strip() in ("ready", "missing", "deleted") + } + if lora_availability: + filters["lora_availability"] = lora_availability + lora_hash = request.query.get("lora_hash") checkpoint_hash = request.query.get("checkpoint_hash") diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index 8ed2b9ca..57cbf576 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -39,6 +39,9 @@ logger = logging.getLogger(__name__) # explicitly to "diffusion_model" (mirrors Oracle R2-F1). _CHECKPOINT_MODEL_TYPE_ALIASES = {"diffusionmodel": "diffusion_model"} +# Valid LoRA availability statuses for the recipe listing filter. +_VALID_LORA_AVAILABILITY_STATUSES = frozenset({"ready", "missing", "deleted"}) + class RecipeScanner: """Service for scanning and managing recipe images""" @@ -2994,6 +2997,43 @@ class RecipeScanner: return lora + def _compute_availability_statuses(self, recipe: Dict[str, Any]) -> Set[str]: + """Compute the LoRA availability status set for a recipe. + + Returns ``{"ready"}`` when every non-excluded LoRA resolves to the + local library (recipes without LoRAs count as ready); otherwise a + subset of ``{"missing", "deleted"}``. Uses the same inLibrary + resolution as ``_enrich_lora_entry`` (hash index with modelVersionId + fallback) but performs only in-memory lookups. + """ + + statuses: Set[str] = set() + for lora in recipe.get("loras") or []: + if not isinstance(lora, dict) or lora.get("exclude"): + continue + + in_library = False + if self._lora_scanner: + hash_value = (lora.get("hash") or "").lower() + if hash_value: + in_library = self._lora_scanner.has_hash(hash_value) + elif lora.get("modelVersionId") is not None: + in_library = ( + self._get_lora_from_version_index(lora.get("modelVersionId")) + is not None + ) + + if in_library: + continue + if lora.get("isDeleted"): + statuses.add("deleted") + else: + statuses.add("missing") + + if not statuses: + statuses.add("ready") + return statuses + def _normalize_preview_url(self, preview_url: Optional[str]) -> Optional[str]: """Return a preview URL that is reachable from the browser.""" @@ -3261,6 +3301,22 @@ class RecipeScanner: if not matches_exclude(item.get("tags")) ] + # Filter by LoRA availability status + availability = filters.get("lora_availability") + if availability: + selected = { + status + for status in availability + if status in _VALID_LORA_AVAILABILITY_STATUSES + } + # Selecting every status (or none) means no filtering. + if 0 < len(selected) < len(_VALID_LORA_AVAILABILITY_STATUSES): + filtered_data = [ + item + for item in filtered_data + if self._compute_availability_statuses(item) & selected + ] + # Apply sorting if not already handled by pre-sorted cache if ":" in sort_by or sort_field in ("loras_count", "random", "opened"): field, order = (sort_by.split(":") + ["desc"])[:2] diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js index 626368e8..ee15b7a7 100644 --- a/static/js/api/recipeApi.js +++ b/static/js/api/recipeApi.js @@ -174,6 +174,11 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) { } }); } + + // Add LoRA availability filter (no statuses selected = no filtering) + if (pageState.filters?.loraAvailability && pageState.filters.loraAvailability.length > 0) { + params.append('lora_availability', pageState.filters.loraAvailability.join(',')); + } } // Fetch recipes diff --git a/static/js/managers/FilterManager.js b/static/js/managers/FilterManager.js index 5d3fbd61..5cc064e2 100644 --- a/static/js/managers/FilterManager.js +++ b/static/js/managers/FilterManager.js @@ -7,6 +7,10 @@ import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js'; import { translate } from '../utils/i18nHelpers.js'; import { FilterPresetManager, EMPTY_WILDCARD_MARKER } from './FilterPresetManager.js'; +// LoRA availability statuses available on the recipes page. No statuses +// selected (the default) means no filtering. +const LORA_AVAILABILITY_STATUSES = ['ready', 'missing', 'deleted']; + export class FilterManager { constructor(options = {}) { this.options = { @@ -74,6 +78,11 @@ export class FilterManager { this.initializeLicenseFilters(); } + // Add click handlers for LoRA availability tags (recipes page only) + if (this.shouldShowLoraAvailabilityFilter()) { + this.initializeLoraAvailabilityFilters(); + } + // Initialize tag logic toggle this.initializeTagLogicToggle(); @@ -421,6 +430,42 @@ export class FilterManager { }); } + initializeLoraAvailabilityFilters() { + const availabilityTags = document.querySelectorAll('.lora-availability-tag'); + availabilityTags.forEach(tag => { + tag.addEventListener('click', async () => { + const status = tag.dataset.availability; + const selected = this.filters.loraAvailability || []; + + if (selected.includes(status)) { + this.filters.loraAvailability = selected.filter(value => value !== status); + tag.classList.remove('active'); + } else { + this.filters.loraAvailability = [...selected, status]; + tag.classList.add('active'); + } + + this.updateActiveFiltersCount(); + await this.applyFilters(false); + }); + }); + + // Update selections based on stored filters + this.updateLoraAvailabilitySelections(); + } + + updateLoraAvailabilitySelections() { + const availabilityTags = document.querySelectorAll('.lora-availability-tag'); + const selected = this.filters.loraAvailability || []; + availabilityTags.forEach(tag => { + if (selected.includes(tag.dataset.availability)) { + tag.classList.add('active'); + } else { + tag.classList.remove('active'); + } + }); + } + createBaseModelTags() { const baseModelTagsContainer = document.getElementById('baseModelTags'); if (!baseModelTagsContainer) return; @@ -681,6 +726,11 @@ export class FilterManager { } this.updateModelTypeSelections(); + // Update LoRA availability tags if visible on this page + if (this.shouldShowLoraAvailabilityFilter()) { + this.updateLoraAvailabilitySelections(); + } + const autoTagEls = document.querySelectorAll('.auto-tag-filter'); autoTagEls.forEach(el => { const tag = el.dataset.autoTag; @@ -708,7 +758,9 @@ export class FilterManager { const modelTypeFilterCount = this.filters.modelTypes.length; // Exclude EMPTY_WILDCARD_MARKER from base model count const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length; - const totalActiveFilters = baseModelCount + tagFilterCount + autoTagFilterCount + licenseFilterCount + modelTypeFilterCount; + // Active when at least one availability status is deselected + const loraAvailabilityCount = this.filters.loraAvailability?.length ?? 0; + const totalActiveFilters = baseModelCount + tagFilterCount + autoTagFilterCount + licenseFilterCount + modelTypeFilterCount + loraAvailabilityCount; if (this.activeFiltersCount) { if (totalActiveFilters > 0) { @@ -805,6 +857,7 @@ export class FilterManager { autoTags: {}, license: {}, modelTypes: [], + loraAvailability: [], tagLogic: 'any' }); @@ -891,12 +944,14 @@ export class FilterManager { const modelTypeCount = this.filters.modelTypes.length; // Exclude EMPTY_WILDCARD_MARKER from base model count const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length; + const loraAvailabilityCount = this.filters.loraAvailability?.length ?? 0; return ( baseModelCount > 0 || tagCount > 0 || autoTagCount > 0 || licenseCount > 0 || - modelTypeCount > 0 + modelTypeCount > 0 || + loraAvailabilityCount > 0 ); } @@ -909,6 +964,7 @@ export class FilterManager { autoTags: this.normalizeTagFilters(source.autoTags), license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {}, modelTypes: this.normalizeModelTypeFilters(source.modelTypes), + loraAvailability: this.normalizeLoraAvailabilityFilters(source.loraAvailability), tagLogic: source.tagLogic || 'any' }; } @@ -917,6 +973,33 @@ export class FilterManager { return this.currentPage !== 'recipes'; } + shouldShowLoraAvailabilityFilter() { + return this.currentPage === 'recipes'; + } + + normalizeLoraAvailabilityFilters(loraAvailability) { + // Default to no statuses selected (= no filtering) + if (!Array.isArray(loraAvailability)) { + return []; + } + + const seen = new Set(); + return loraAvailability.reduce((acc, status) => { + if (typeof status !== 'string') { + return acc; + } + + const normalized = status.trim().toLowerCase(); + if (!LORA_AVAILABILITY_STATUSES.includes(normalized) || seen.has(normalized)) { + return acc; + } + + seen.add(normalized); + acc.push(normalized); + return acc; + }, []); + } + normalizeTagFilters(tagFilters) { if (!tagFilters) { return {}; @@ -994,6 +1077,7 @@ export class FilterManager { autoTags: { ...(this.filters.autoTags || {}) }, license: { ...(this.filters.license || {}) }, modelTypes: [...(this.filters.modelTypes || [])], + loraAvailability: [...(this.filters.loraAvailability || [])], tagLogic: this.filters.tagLogic || 'any', search: pageState?.filters?.search ?? '' }; diff --git a/static/js/state/index.js b/static/js/state/index.js index d32f773a..dc7815e1 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -148,6 +148,7 @@ export const state = { tags: {}, license: {}, modelTypes: [], + loraAvailability: [], search: '' }, pageSize: 20, diff --git a/templates/components/header.html b/templates/components/header.html index 14049c02..05912cd1 100644 --- a/templates/components/header.html +++ b/templates/components/header.html @@ -264,6 +264,22 @@ {{ t('header.filter.noTagMatches') }} + {% if current_page == 'recipes' %} +
+

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

+
+
+ {{ t('header.filter.availabilityReady') }} +
+
+ {{ t('header.filter.availabilityMissing') }} +
+
+ {{ t('header.filter.availabilityDeleted') }} +
+
+
+ {% endif %} {% if current_page == 'loras' or current_page == 'checkpoints' %}

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

diff --git a/tests/frontend/api/recipeApi.loraAvailability.test.js b/tests/frontend/api/recipeApi.loraAvailability.test.js new file mode 100644 index 00000000..32451435 --- /dev/null +++ b/tests/frontend/api/recipeApi.loraAvailability.test.js @@ -0,0 +1,100 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const getCurrentPageStateMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: vi.fn(), +})); + +vi.mock('../../../static/js/components/RecipeCard.js', () => ({ + RecipeCard: vi.fn(() => ({ element: document.createElement('div') })), +})); + +vi.mock('../../../static/js/state/index.js', () => ({ + state: { + loadingManager: { + showSimpleLoading: vi.fn(), + hide: vi.fn(), + }, + }, + getCurrentPageState: getCurrentPageStateMock, +})); + +vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({ + captureScrollPosition: vi.fn(), + restoreScrollPosition: vi.fn(), + recreateVirtualScroll: vi.fn(), +})); + +import { fetchRecipesPage } from '../../../static/js/api/recipeApi.js'; + +function makePageState(loraAvailability) { + return { + pageSize: 50, + currentPage: 1, + hasMore: true, + isLoading: false, + sortBy: 'date:desc', + showFavoritesOnly: false, + activeFolder: null, + searchOptions: { recursive: true }, + customFilter: { active: false }, + filters: { loraAvailability }, + }; +} + +describe('fetchRecipesPage lora_availability param', () => { + beforeEach(() => { + vi.clearAllMocks(); + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ items: [], total: 0, total_pages: 0 }), + }); + }); + + afterEach(() => { + delete global.fetch; + }); + + it('appends lora_availability when a subset of statuses is selected', async () => { + getCurrentPageStateMock.mockReturnValue(makePageState(['missing', 'deleted'])); + + await fetchRecipesPage(1, 50); + + const url = global.fetch.mock.calls[0][0]; + const params = new URL(url, 'http://localhost').searchParams; + expect(params.get('lora_availability')).toBe('missing,deleted'); + }); + + it('appends lora_availability when all statuses are selected (backend treats it as show-all)', async () => { + getCurrentPageStateMock.mockReturnValue( + makePageState(['ready', 'missing', 'deleted']) + ); + + await fetchRecipesPage(1, 50); + + const url = global.fetch.mock.calls[0][0]; + const params = new URL(url, 'http://localhost').searchParams; + expect(params.get('lora_availability')).toBe('ready,missing,deleted'); + }); + + it('omits lora_availability when no statuses are selected', async () => { + getCurrentPageStateMock.mockReturnValue(makePageState([])); + + await fetchRecipesPage(1, 50); + + const url = global.fetch.mock.calls[0][0]; + const params = new URL(url, 'http://localhost').searchParams; + expect(params.get('lora_availability')).toBeNull(); + }); + + it('omits lora_availability when the filter is absent', async () => { + getCurrentPageStateMock.mockReturnValue(makePageState(undefined)); + + await fetchRecipesPage(1, 50); + + const url = global.fetch.mock.calls[0][0]; + const params = new URL(url, 'http://localhost').searchParams; + expect(params.get('lora_availability')).toBeNull(); + }); +}); diff --git a/tests/frontend/managers/FilterManager.loraAvailability.test.js b/tests/frontend/managers/FilterManager.loraAvailability.test.js new file mode 100644 index 00000000..36399890 --- /dev/null +++ b/tests/frontend/managers/FilterManager.loraAvailability.test.js @@ -0,0 +1,266 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mock dependencies +vi.mock('../../../static/js/state/index.js', () => ({ + getCurrentPageState: vi.fn(() => ({ + filters: {}, + })), + state: { + currentPageType: 'recipes', + loadingManager: { + showSimpleLoading: vi.fn(), + hide: vi.fn(), + }, + }, +})); + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: vi.fn(), + updatePanelPositions: vi.fn(), +})); + +vi.mock('../../../static/js/api/modelApiFactory.js', () => ({ + getModelApiClient: vi.fn(() => ({ + loadMoreWithVirtualScroll: vi.fn().mockResolvedValue(), + })), +})); + +vi.mock('../../../static/js/utils/storageHelpers.js', () => ({ + getStorageItem: vi.fn(), + setStorageItem: vi.fn(), + removeStorageItem: vi.fn(), +})); + +vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({ + translate: vi.fn((key, _params, fallback) => fallback || key), +})); + +vi.mock('../../../static/js/managers/FilterPresetManager.js', () => ({ + FilterPresetManager: vi.fn().mockImplementation(() => ({ + renderPresets: vi.fn(), + saveActivePreset: vi.fn(), + restoreActivePreset: vi.fn(), + updateAddButtonState: vi.fn(), + hasEmptyWildcardResult: vi.fn(() => false), + })), + EMPTY_WILDCARD_MARKER: '__EMPTY_WILDCARD_RESULT__', +})); + +import { FilterManager } from '../../../static/js/managers/FilterManager.js'; +import { getStorageItem } from '../../../static/js/utils/storageHelpers.js'; + +const ALL_STATUSES = ['ready', 'missing', 'deleted']; + +describe('FilterManager - LoRA Availability', () => { + let manager; + let mockFilterPanel; + let mockActiveFiltersCount; + + function createAvailabilityTags() { + const container = document.createElement('div'); + container.id = 'loraAvailabilityTags'; + ALL_STATUSES.forEach(status => { + const tag = document.createElement('div'); + tag.className = 'filter-tag lora-availability-tag'; + tag.dataset.availability = status; + container.appendChild(tag); + }); + document.body.appendChild(container); + return container; + } + + beforeEach(() => { + vi.clearAllMocks(); + getStorageItem.mockReturnValue(undefined); + document.body.innerHTML = ''; + + mockFilterPanel = document.createElement('div'); + mockFilterPanel.id = 'filterPanel'; + mockFilterPanel.classList.add('hidden'); + document.body.appendChild(mockFilterPanel); + + mockActiveFiltersCount = document.createElement('span'); + createAvailabilityTags(); + + const originalGetElementById = document.getElementById; + document.getElementById = vi.fn((id) => { + if (id === 'filterPanel') return mockFilterPanel; + if (id === 'filterButton') return document.createElement('button'); + if (id === 'activeFiltersCount') return mockActiveFiltersCount; + if (id === 'baseModelTags') return document.createElement('div'); + if (id === 'modelTypeTags') return document.createElement('div'); + return originalGetElementById.call(document, id); + }); + }); + + describe('initializeFilters', () => { + it('should default to no statuses selected on the recipes page', () => { + manager = new FilterManager({ page: 'recipes' }); + + expect(manager.filters.loraAvailability).toEqual([]); + }); + + it('should restore a saved selection from storage', () => { + getStorageItem.mockReturnValue({ + baseModel: [], + tags: {}, + loraAvailability: ['missing'], + }); + + manager = new FilterManager({ page: 'recipes' }); + + expect(manager.filters.loraAvailability).toEqual(['missing']); + }); + + it('should drop invalid stored values', () => { + getStorageItem.mockReturnValue({ + baseModel: [], + tags: {}, + loraAvailability: ['missing', 'bogus', 'missing'], + }); + + manager = new FilterManager({ page: 'recipes' }); + + expect(manager.filters.loraAvailability).toEqual(['missing']); + }); + + it('should default to no statuses when the stored value is not an array', () => { + getStorageItem.mockReturnValue({ + baseModel: [], + tags: {}, + loraAvailability: 'missing', + }); + + manager = new FilterManager({ page: 'recipes' }); + + expect(manager.filters.loraAvailability).toEqual([]); + }); + }); + + describe('hasActiveFilters', () => { + it('should be inactive when no statuses are selected', () => { + manager = new FilterManager({ page: 'recipes' }); + + expect(manager.hasActiveFilters()).toBe(false); + }); + + it('should be active when at least one status is selected', () => { + getStorageItem.mockReturnValue({ + baseModel: [], + tags: {}, + loraAvailability: ['ready'], + }); + + manager = new FilterManager({ page: 'recipes' }); + + expect(manager.hasActiveFilters()).toBe(true); + }); + }); + + describe('updateActiveFiltersCount', () => { + it('should count selected statuses', () => { + getStorageItem.mockReturnValue({ + baseModel: [], + tags: {}, + loraAvailability: ['missing', 'deleted'], + }); + + manager = new FilterManager({ page: 'recipes' }); + + expect(mockActiveFiltersCount.textContent).toBe('2'); + }); + }); + + describe('chip interaction', () => { + it('should select a status when its chip is clicked', async () => { + manager = new FilterManager({ page: 'recipes' }); + + const readyTag = document.querySelector('[data-availability="ready"]'); + expect(readyTag.classList.contains('active')).toBe(false); + + readyTag.click(); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(manager.filters.loraAvailability).toEqual(['ready']); + expect(readyTag.classList.contains('active')).toBe(true); + }); + + it('should deselect a selected status when its chip is clicked again', async () => { + getStorageItem.mockReturnValue({ + baseModel: [], + tags: {}, + loraAvailability: ['ready'], + }); + + manager = new FilterManager({ page: 'recipes' }); + + const readyTag = document.querySelector('[data-availability="ready"]'); + // Restored state should mark the chip active + expect(readyTag.classList.contains('active')).toBe(true); + + readyTag.click(); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(manager.filters.loraAvailability).toEqual([]); + expect(readyTag.classList.contains('active')).toBe(false); + }); + + it('should mark all chips active when a stored all-statuses array is restored', () => { + // Legacy stored value: all statuses selected. Under positive + // selection semantics the backend treats this as show-all. + getStorageItem.mockReturnValue({ + baseModel: [], + tags: {}, + loraAvailability: [...ALL_STATUSES], + }); + + manager = new FilterManager({ page: 'recipes' }); + + expect(manager.filters.loraAvailability).toEqual(ALL_STATUSES); + document.querySelectorAll('.lora-availability-tag').forEach(tag => { + expect(tag.classList.contains('active')).toBe(true); + }); + }); + }); + + describe('cloneFilters', () => { + it('should include loraAvailability in cloned filters', () => { + getStorageItem.mockReturnValue({ + baseModel: [], + tags: {}, + loraAvailability: ['deleted'], + }); + + manager = new FilterManager({ page: 'recipes' }); + + const cloned = manager.cloneFilters(); + + expect(cloned.loraAvailability).toEqual(['deleted']); + }); + + it('should clone an empty selection as an empty array', () => { + manager = new FilterManager({ page: 'recipes' }); + + const cloned = manager.cloneFilters(); + + expect(cloned.loraAvailability).toEqual([]); + }); + }); + + describe('clearFilters', () => { + it('should reset loraAvailability to no statuses selected', () => { + getStorageItem.mockReturnValue({ + baseModel: [], + tags: {}, + loraAvailability: ['deleted'], + }); + + manager = new FilterManager({ page: 'recipes' }); + expect(manager.filters.loraAvailability).toEqual(['deleted']); + + manager.clearFilters(); + + expect(manager.filters.loraAvailability).toEqual([]); + }); + }); +}); diff --git a/tests/routes/test_recipe_routes.py b/tests/routes/test_recipe_routes.py index af632cab..53b54a52 100644 --- a/tests/routes/test_recipe_routes.py +++ b/tests/routes/test_recipe_routes.py @@ -632,6 +632,43 @@ async def test_list_recipes_passes_checkpoint_hash_filter( assert harness.scanner.last_paginated_params["checkpoint_hash"] == "ckpt123" +async def test_list_recipes_passes_lora_availability_filter( + monkeypatch, tmp_path: Path +) -> None: + async with recipe_harness(monkeypatch, tmp_path) as harness: + response = await harness.client.get( + "/api/lm/recipes?lora_availability=missing,deleted" + ) + payload = await response.json() + + assert response.status == 200 + assert payload["items"] == [] + assert harness.scanner.last_paginated_params is not None + filters = harness.scanner.last_paginated_params["filters"] + assert filters["lora_availability"] == {"missing", "deleted"} + + +async def test_list_recipes_ignores_invalid_lora_availability_values( + monkeypatch, tmp_path: Path +) -> None: + async with recipe_harness(monkeypatch, tmp_path) as harness: + # Valid values are kept, invalid ones dropped + response = await harness.client.get( + "/api/lm/recipes?lora_availability=bogus,ready" + ) + assert response.status == 200 + assert harness.scanner.last_paginated_params is not None + filters = harness.scanner.last_paginated_params["filters"] + assert filters["lora_availability"] == {"ready"} + + # No valid values at all -> no availability filter + response = await harness.client.get("/api/lm/recipes?lora_availability=bogus") + assert response.status == 200 + assert harness.scanner.last_paginated_params is not None + filters = harness.scanner.last_paginated_params["filters"] + assert "lora_availability" not in filters + + async def test_get_recipes_for_checkpoint(monkeypatch, tmp_path: Path) -> None: async with recipe_harness(monkeypatch, tmp_path) as harness: harness.scanner.checkpoint_lookup["abc123"] = [ diff --git a/tests/services/test_recipe_lora_availability_filter.py b/tests/services/test_recipe_lora_availability_filter.py new file mode 100644 index 00000000..46a215e0 --- /dev/null +++ b/tests/services/test_recipe_lora_availability_filter.py @@ -0,0 +1,138 @@ +"""Tests for the LoRA availability filter on the recipe listing.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from py.config import config +from py.services.recipe_scanner import RecipeScanner + + +class StubLoraScanner: + """In-memory lora scanner double exposing the hash/version indexes.""" + + def __init__(self, hashes=(), version_index=None): + self._hashes = {value.lower() for value in hashes} + self._cache = SimpleNamespace(raw_data=[], version_index=version_index or {}) + + def has_hash(self, hash_value): + return hash_value.lower() in self._hashes + + def get_preview_url_by_hash(self, hash_value): + return None + + def get_path_by_hash(self, hash_value): + return None + + async def get_cached_data(self): + return self._cache + + async def refresh_cache(self, force=False): + pass + + +@pytest.fixture +def recipe_scanner(tmp_path, monkeypatch): + monkeypatch.setattr(config, "loras_roots", [str(tmp_path)]) + lora_scanner = StubLoraScanner( + hashes={"aaa111"}, + version_index={42: {"file_path": "/loras/from-version.safetensors"}}, + ) + scanner = RecipeScanner(lora_scanner=lora_scanner) # pyright: ignore[reportArgumentType] + + recipes = [ + # All loras in library (hash matching is case-insensitive) -> ready + {"id": "r1", "title": "Ready", "loras": [{"hash": "AAA111"}]}, + # One lora not in library -> missing + { + "id": "r2", + "title": "Missing", + "loras": [{"hash": "aaa111"}, {"hash": "bbb222"}], + }, + # Lora deleted on Civitai and not in library -> deleted + { + "id": "r3", + "title": "Deleted", + "loras": [{"hash": "ccc333", "isDeleted": True}], + }, + # Missing + deleted -> both statuses + { + "id": "r4", + "title": "Mixed", + "loras": [{"hash": "bbb222"}, {"hash": "ccc333", "isDeleted": True}], + }, + # No loras at all -> ready + {"id": "r5", "title": "Empty", "loras": []}, + # Excluded lora is ignored -> ready + { + "id": "r6", + "title": "Excluded", + "loras": [{"hash": "bbb222", "exclude": True}], + }, + # modelVersionId resolves via the version index -> ready + {"id": "r7", "title": "VersionFallback", "loras": [{"modelVersionId": 42}]}, + ] + + scanner._cache = SimpleNamespace( + raw_data=recipes, + sorted_by_date=recipes, + sorted_by_name=recipes, + ) + return scanner + + +async def _fetch_ids(scanner, filters=None): + result = await scanner.get_paginated_data(page=1, page_size=50, filters=filters) + return {item["id"] for item in result["items"]} + + +@pytest.mark.asyncio +async def test_availability_filter_ready_only(recipe_scanner): + ids = await _fetch_ids(recipe_scanner, {"lora_availability": {"ready"}}) + assert ids == {"r1", "r5", "r6", "r7"} + + +@pytest.mark.asyncio +async def test_availability_filter_missing_only(recipe_scanner): + ids = await _fetch_ids(recipe_scanner, {"lora_availability": {"missing"}}) + assert ids == {"r2", "r4"} + + +@pytest.mark.asyncio +async def test_availability_filter_deleted_only(recipe_scanner): + ids = await _fetch_ids(recipe_scanner, {"lora_availability": {"deleted"}}) + assert ids == {"r3", "r4"} + + +@pytest.mark.asyncio +async def test_availability_filter_missing_and_deleted(recipe_scanner): + ids = await _fetch_ids(recipe_scanner, {"lora_availability": {"missing", "deleted"}}) + assert ids == {"r2", "r3", "r4"} + + +@pytest.mark.asyncio +async def test_availability_filter_all_statuses_disables_filtering(recipe_scanner): + ids = await _fetch_ids( + recipe_scanner, {"lora_availability": {"ready", "missing", "deleted"}} + ) + assert ids == {"r1", "r2", "r3", "r4", "r5", "r6", "r7"} + + +@pytest.mark.asyncio +async def test_availability_filter_absent_or_invalid_disables_filtering(recipe_scanner): + assert await _fetch_ids(recipe_scanner) == {"r1", "r2", "r3", "r4", "r5", "r6", "r7"} + ids = await _fetch_ids(recipe_scanner, {"lora_availability": {"bogus"}}) + assert ids == {"r1", "r2", "r3", "r4", "r5", "r6", "r7"} + + +@pytest.mark.asyncio +async def test_availability_filter_counts_and_pagination(recipe_scanner): + # Filtering happens before pagination, so totals reflect the filtered set. + result = await recipe_scanner.get_paginated_data( + page=1, page_size=1, filters={"lora_availability": {"ready"}} + ) + assert result["total"] == 4 + assert result["total_pages"] == 4 + assert len(result["items"]) == 1