feat(recipes): add lora availability filter to recipe filter panel

This commit is contained in:
Will Miao
2026-08-24 16:59:42 +08:00
parent 20f66a4fe1
commit 6f5c444ec5
20 changed files with 763 additions and 12 deletions
@@ -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();
});
});
@@ -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([]);
});
});
});
+37
View File
@@ -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"] = [
@@ -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