mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-15 18:23:21 -03:00
feat(recipes): add sort by random option with seeded stable pagination
This commit is contained in:
@@ -8,6 +8,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
import time
|
import time
|
||||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union, cast
|
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union, cast
|
||||||
from ..config import config
|
from ..config import config
|
||||||
@@ -2781,7 +2782,9 @@ class RecipeScanner:
|
|||||||
Args:
|
Args:
|
||||||
page: Current page number (1-based)
|
page: Current page number (1-based)
|
||||||
page_size: Number of items per page
|
page_size: Number of items per page
|
||||||
sort_by: Sort method ('name' or 'date')
|
sort_by: Sort method ('name', 'date', 'loras_count', or 'random'
|
||||||
|
with an optional seed like 'random:abc123'; the part after
|
||||||
|
'random:' is the shuffle seed, not a direction)
|
||||||
search: Search term
|
search: Search term
|
||||||
filters: Dictionary of filters to apply
|
filters: Dictionary of filters to apply
|
||||||
search_options: Dictionary of search options to apply
|
search_options: Dictionary of search options to apply
|
||||||
@@ -2962,7 +2965,7 @@ class RecipeScanner:
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Apply sorting if not already handled by pre-sorted cache
|
# Apply sorting if not already handled by pre-sorted cache
|
||||||
if ":" in sort_by or sort_field == "loras_count":
|
if ":" in sort_by or sort_field in ("loras_count", "random"):
|
||||||
field, order = (sort_by.split(":") + ["desc"])[:2]
|
field, order = (sort_by.split(":") + ["desc"])[:2]
|
||||||
reverse = order.lower() == "desc"
|
reverse = order.lower() == "desc"
|
||||||
|
|
||||||
@@ -2985,6 +2988,12 @@ class RecipeScanner:
|
|||||||
filtered_data.sort(
|
filtered_data.sort(
|
||||||
key=lambda x: len(x.get("loras", [])), reverse=reverse
|
key=lambda x: len(x.get("loras", [])), reverse=reverse
|
||||||
)
|
)
|
||||||
|
elif field == "random":
|
||||||
|
# Seeded random shuffle: same seed -> same order (stable
|
||||||
|
# pagination across requests), matching the model pages.
|
||||||
|
seed = order if order.lower() not in ("asc", "desc") else None
|
||||||
|
rng = random.Random(seed or "random")
|
||||||
|
rng.shuffle(filtered_data)
|
||||||
|
|
||||||
# Calculate pagination
|
# Calculate pagination
|
||||||
total_items = len(filtered_data)
|
total_items = len(filtered_data)
|
||||||
|
|||||||
+51
-3
@@ -245,10 +245,20 @@ class RecipeManager {
|
|||||||
this.pageState.sortBy = savedSort;
|
this.pageState.sortBy = savedSort;
|
||||||
}
|
}
|
||||||
initSortDropdown(sortSelect);
|
initSortDropdown(sortSelect);
|
||||||
sortSelect.value = this.pageState.sortBy || 'date:desc';
|
this.applySortToSelect(this.pageState.sortBy || 'date:desc');
|
||||||
sortSelect.addEventListener('change', () => {
|
sortSelect.addEventListener('change', () => {
|
||||||
this.pageState.sortBy = sortSelect.value;
|
let value = sortSelect.value;
|
||||||
setStorageItem('recipes_sort', sortSelect.value);
|
if (value.startsWith('random')) {
|
||||||
|
// Every pick of Random reshuffles the list: generate a
|
||||||
|
// fresh seed so the backend keeps a stable order across
|
||||||
|
// paginated requests.
|
||||||
|
value = this._randomizeSortValue();
|
||||||
|
}
|
||||||
|
this.pageState.sortBy = value;
|
||||||
|
setStorageItem('recipes_sort', value);
|
||||||
|
// Reset the seeded Random option when switching away from
|
||||||
|
// Random, or re-apply the fresh seed when picking it again.
|
||||||
|
this.applySortToSelect(value);
|
||||||
refreshVirtualScroll();
|
refreshVirtualScroll();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -341,6 +351,44 @@ class RecipeManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a sort value to the native sort <select>, keeping the Random
|
||||||
|
* option's value in sync when the persisted value carries a seed
|
||||||
|
* (e.g. "random:abc123"). Must be used instead of assigning
|
||||||
|
* sortSelect.value directly whenever the value may be a seeded random
|
||||||
|
* sort, otherwise the native select has no matching option.
|
||||||
|
* @param {string} sortValue - Sort value like "date:desc" or "random:<seed>"
|
||||||
|
*/
|
||||||
|
applySortToSelect(sortValue) {
|
||||||
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
|
if (!sortSelect) return;
|
||||||
|
const randomOpt = sortSelect.querySelector('option[value="random"], option[value^="random:"]');
|
||||||
|
if (randomOpt) {
|
||||||
|
randomOpt.value = String(sortValue).startsWith('random') ? sortValue : 'random';
|
||||||
|
}
|
||||||
|
sortSelect.value = sortValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a fresh seeded random sort value ("random:<seed>") and keep
|
||||||
|
* the native <select> in sync so its value matches the persisted sort
|
||||||
|
* string and the dropdown shows the selected label.
|
||||||
|
* @returns {string} The new sort value, e.g. "random:abc123xyz"
|
||||||
|
*/
|
||||||
|
_randomizeSortValue() {
|
||||||
|
const seed = Math.random().toString(36).slice(2, 12);
|
||||||
|
const value = `random:${seed}`;
|
||||||
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
|
if (sortSelect) {
|
||||||
|
const randomOpt = sortSelect.querySelector('option[value="random"], option[value^="random:"]');
|
||||||
|
if (randomOpt) {
|
||||||
|
randomOpt.value = value;
|
||||||
|
}
|
||||||
|
sortSelect.value = value;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
normalizeLoadRecipesOptions(options = true) {
|
normalizeLoadRecipesOptions(options = true) {
|
||||||
if (typeof options === 'boolean') {
|
if (typeof options === 'boolean') {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -48,17 +48,15 @@
|
|||||||
<option value="versions_count:asc">{{ t('loras.controls.sort.versionsCountAsc', default='Fewest versions first') }}</option>
|
<option value="versions_count:asc">{{ t('loras.controls.sort.versionsCountAsc', default='Fewest versions first') }}</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if page_id != 'recipes' %}
|
|
||||||
<optgroup label="{{ t('loras.controls.sort.random', default='Random') }}">
|
|
||||||
<option value="random">{{ t('loras.controls.sort.randomAction', default='Randomize (shuffle)') }}</option>
|
|
||||||
</optgroup>
|
|
||||||
{% endif %}
|
|
||||||
{% if page_id == 'recipes' %}
|
{% if page_id == 'recipes' %}
|
||||||
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
|
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
|
||||||
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
|
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
|
||||||
<option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option>
|
<option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<optgroup label="{{ t('loras.controls.sort.random', default='Random') }}">
|
||||||
|
<option value="random">{{ t('loras.controls.sort.randomAction', default='Randomize (shuffle)') }}</option>
|
||||||
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.title') }}{% else %}{{ t('loras.controls.refresh.title') }}{% endif %}" class="control-group dropdown-group">
|
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.title') }}{% else %}{{ t('loras.controls.refresh.title') }}{% endif %}" class="control-group dropdown-group">
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { renderRecipesPage } from '../utils/pageFixtures.js';
|
||||||
|
|
||||||
|
const initializeAppMock = vi.fn();
|
||||||
|
const initializePageFeaturesMock = vi.fn();
|
||||||
|
const getCurrentPageStateMock = vi.fn();
|
||||||
|
const getSessionItemMock = vi.fn();
|
||||||
|
const removeSessionItemMock = vi.fn();
|
||||||
|
const getStorageItemMock = vi.fn();
|
||||||
|
const setStorageItemMock = vi.fn();
|
||||||
|
const removeStorageItemMock = vi.fn();
|
||||||
|
const refreshVirtualScrollMock = vi.fn();
|
||||||
|
const refreshRecipesMock = vi.fn();
|
||||||
|
|
||||||
|
let importManagerInstance;
|
||||||
|
let recipeModalInstance;
|
||||||
|
let duplicatesManagerInstance;
|
||||||
|
|
||||||
|
const ImportManagerMock = vi.fn(() => importManagerInstance);
|
||||||
|
const RecipeModalMock = vi.fn(() => recipeModalInstance);
|
||||||
|
const DuplicatesManagerMock = vi.fn(() => duplicatesManagerInstance);
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/core.js', () => ({
|
||||||
|
appCore: {
|
||||||
|
initialize: initializeAppMock,
|
||||||
|
initializePageFeatures: initializePageFeaturesMock,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/managers/ImportManager.js', () => ({
|
||||||
|
ImportManager: ImportManagerMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/components/RecipeModal.js', () => ({
|
||||||
|
RecipeModal: RecipeModalMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/state/index.js', () => ({
|
||||||
|
getCurrentPageState: getCurrentPageStateMock,
|
||||||
|
state: {
|
||||||
|
currentPageType: 'recipes',
|
||||||
|
global: { settings: {} },
|
||||||
|
virtualScroller: {
|
||||||
|
removeItemByFilePath: vi.fn(),
|
||||||
|
updateSingleItem: vi.fn(),
|
||||||
|
refreshWithData: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||||
|
getSessionItem: getSessionItemMock,
|
||||||
|
removeSessionItem: removeSessionItemMock,
|
||||||
|
getStorageItem: getStorageItemMock,
|
||||||
|
setStorageItem: setStorageItemMock,
|
||||||
|
removeStorageItem: removeStorageItemMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({
|
||||||
|
RecipeContextMenu: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/components/DuplicatesManager.js', () => ({
|
||||||
|
DuplicatesManager: DuplicatesManagerMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
||||||
|
refreshVirtualScroll: refreshVirtualScrollMock,
|
||||||
|
recreateVirtualScroll: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||||
|
refreshRecipes: refreshRecipesMock,
|
||||||
|
RecipeSidebarApiClient: vi.fn(() => ({
|
||||||
|
apiConfig: { config: { displayName: 'Recipes', supportsMove: true } },
|
||||||
|
fetchUnifiedFolderTree: vi.fn().mockResolvedValue({ success: true, tree: {} }),
|
||||||
|
fetchModelFolders: vi.fn().mockResolvedValue({ success: true, folders: [] }),
|
||||||
|
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/recipes'] }),
|
||||||
|
moveBulkModels: vi.fn(),
|
||||||
|
moveSingleModel: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/components/SidebarManager.js', () => ({
|
||||||
|
sidebarManager: {
|
||||||
|
setHostPageControls: vi.fn(),
|
||||||
|
initialize: vi.fn(async () => {}),
|
||||||
|
refresh: vi.fn(async () => {}),
|
||||||
|
cleanup: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderSortSelect() {
|
||||||
|
const sortSelectElement = document.createElement('select');
|
||||||
|
sortSelectElement.id = 'sortSelect';
|
||||||
|
sortSelectElement.innerHTML = `
|
||||||
|
<option value="date:desc">Newest</option>
|
||||||
|
<option value="name:asc">Name A-Z</option>
|
||||||
|
<option value="random">Randomize (shuffle)</option>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(sortSelectElement);
|
||||||
|
return sortSelectElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RecipeManager Random sort', () => {
|
||||||
|
let RecipeManager;
|
||||||
|
let pageState;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
importManagerInstance = { showImportModal: vi.fn() };
|
||||||
|
recipeModalInstance = { showRecipeDetails: vi.fn() };
|
||||||
|
duplicatesManagerInstance = {
|
||||||
|
findDuplicates: vi.fn(),
|
||||||
|
selectLatestDuplicates: vi.fn(),
|
||||||
|
deleteSelectedDuplicates: vi.fn(),
|
||||||
|
confirmDeleteDuplicates: vi.fn(),
|
||||||
|
exitDuplicateMode: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
pageState = {
|
||||||
|
sortBy: 'date:desc',
|
||||||
|
searchOptions: undefined,
|
||||||
|
customFilter: undefined,
|
||||||
|
duplicatesMode: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
getCurrentPageStateMock.mockImplementation(() => pageState);
|
||||||
|
initializeAppMock.mockResolvedValue(undefined);
|
||||||
|
initializePageFeaturesMock.mockResolvedValue(undefined);
|
||||||
|
refreshVirtualScrollMock.mockImplementation(() => {});
|
||||||
|
refreshRecipesMock.mockResolvedValue('refreshed');
|
||||||
|
getSessionItemMock.mockImplementation(() => null);
|
||||||
|
removeSessionItemMock.mockImplementation(() => {});
|
||||||
|
getStorageItemMock.mockImplementation(() => null);
|
||||||
|
setStorageItemMock.mockImplementation(() => {});
|
||||||
|
|
||||||
|
renderRecipesPage();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
delete window.recipeManager;
|
||||||
|
delete window.importManager;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createManager() {
|
||||||
|
({ RecipeManager } = await import('../../../static/js/recipes.js'));
|
||||||
|
const manager = new RecipeManager();
|
||||||
|
await manager.initialize();
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('generates a seeded sort value when Random is picked', async () => {
|
||||||
|
const sortSelect = renderSortSelect();
|
||||||
|
const randomOpt = sortSelect.querySelector('option[value="random"]');
|
||||||
|
await createManager();
|
||||||
|
|
||||||
|
sortSelect.value = 'random';
|
||||||
|
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(pageState.sortBy).toMatch(/^random:[a-z0-9]+$/);
|
||||||
|
expect(setStorageItemMock).toHaveBeenCalledWith('recipes_sort', pageState.sortBy);
|
||||||
|
expect(randomOpt.value).toBe(pageState.sortBy);
|
||||||
|
expect(sortSelect.value).toBe(pageState.sortBy);
|
||||||
|
expect(refreshVirtualScrollMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reshuffles with a fresh seed every time Random is picked again', async () => {
|
||||||
|
const sortSelect = renderSortSelect();
|
||||||
|
const randomOpt = sortSelect.querySelector('option[value="random"]');
|
||||||
|
await createManager();
|
||||||
|
|
||||||
|
sortSelect.value = 'random';
|
||||||
|
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
const firstSeed = pageState.sortBy;
|
||||||
|
|
||||||
|
sortSelect.value = randomOpt.value;
|
||||||
|
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(pageState.sortBy).toMatch(/^random:[a-z0-9]+$/);
|
||||||
|
expect(pageState.sortBy).not.toBe(firstSeed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores a persisted seeded random sort on load', async () => {
|
||||||
|
const sortSelect = renderSortSelect();
|
||||||
|
const savedSort = 'random:persistedseed';
|
||||||
|
getStorageItemMock.mockImplementation((key) =>
|
||||||
|
key === 'recipes_sort' ? savedSort : null
|
||||||
|
);
|
||||||
|
await createManager();
|
||||||
|
|
||||||
|
expect(pageState.sortBy).toBe(savedSort);
|
||||||
|
expect(sortSelect.value).toBe(savedSort);
|
||||||
|
expect(sortSelect.querySelector('option[value="random:persistedseed"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies a non-random sort back to the plain random option', async () => {
|
||||||
|
const sortSelect = renderSortSelect();
|
||||||
|
const randomOpt = sortSelect.querySelector('option[value="random"]');
|
||||||
|
const manager = await createManager();
|
||||||
|
|
||||||
|
sortSelect.value = 'random';
|
||||||
|
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
manager.applySortToSelect('name:asc');
|
||||||
|
|
||||||
|
expect(sortSelect.value).toBe('name:asc');
|
||||||
|
expect(randomOpt.value).toBe('random');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets the seeded option when switching away from Random via the change handler', async () => {
|
||||||
|
const sortSelect = renderSortSelect();
|
||||||
|
const randomOpt = sortSelect.querySelector('option[value="random"]');
|
||||||
|
await createManager();
|
||||||
|
|
||||||
|
sortSelect.value = 'random';
|
||||||
|
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(randomOpt.value).toMatch(/^random:[a-z0-9]+$/);
|
||||||
|
|
||||||
|
sortSelect.value = 'name:asc';
|
||||||
|
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(pageState.sortBy).toBe('name:asc');
|
||||||
|
expect(sortSelect.value).toBe('name:asc');
|
||||||
|
expect(randomOpt.value).toBe('random');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1047,6 +1047,54 @@ async def test_get_paginated_data_sorting(recipe_scanner):
|
|||||||
assert [i["id"] for i in res["items"]] == ["C", "A", "B"]
|
assert [i["id"] for i in res["items"]] == ["C", "A", "B"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_paginated_data_random_sort(recipe_scanner):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
|
||||||
|
# Add test recipes
|
||||||
|
for rid, title in [("A", "Alpha"), ("B", "Beta"), ("C", "Gamma")]:
|
||||||
|
await scanner.add_recipe(
|
||||||
|
{
|
||||||
|
"id": rid,
|
||||||
|
"title": title,
|
||||||
|
"created_date": 10.0,
|
||||||
|
"loras": [{}],
|
||||||
|
"file_path": f"{rid.lower()}.png",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
await _wait_for_resort(scanner)
|
||||||
|
|
||||||
|
# Same seed -> same order (deterministic, stable pagination)
|
||||||
|
res1 = await scanner.get_paginated_data(
|
||||||
|
page=1, page_size=10, sort_by="random:seed123"
|
||||||
|
)
|
||||||
|
res2 = await scanner.get_paginated_data(
|
||||||
|
page=1, page_size=10, sort_by="random:seed123"
|
||||||
|
)
|
||||||
|
ids1 = [i["id"] for i in res1["items"]]
|
||||||
|
ids2 = [i["id"] for i in res2["items"]]
|
||||||
|
assert ids1 == ids2
|
||||||
|
assert sorted(ids1) == ["A", "B", "C"]
|
||||||
|
|
||||||
|
# Plain "random" (no seed) also returns the full set
|
||||||
|
res3 = await scanner.get_paginated_data(page=1, page_size=10, sort_by="random")
|
||||||
|
assert sorted(i["id"] for i in res3["items"]) == ["A", "B", "C"]
|
||||||
|
|
||||||
|
# Stable pagination: page1 + page2 with the same seed concatenate to the
|
||||||
|
# full seeded order, with no duplicates across pages
|
||||||
|
p1 = await scanner.get_paginated_data(
|
||||||
|
page=1, page_size=2, sort_by="random:seed123"
|
||||||
|
)
|
||||||
|
p2 = await scanner.get_paginated_data(
|
||||||
|
page=2, page_size=2, sort_by="random:seed123"
|
||||||
|
)
|
||||||
|
combined = [i["id"] for i in p1["items"]] + [i["id"] for i in p2["items"]]
|
||||||
|
assert combined == ids1
|
||||||
|
assert len(set(combined)) == 3
|
||||||
|
|
||||||
|
|
||||||
async def test_build_image_id_map_filters_correctly(recipe_scanner):
|
async def test_build_image_id_map_filters_correctly(recipe_scanner):
|
||||||
"""Only recipes with valid CivitAI source_path appear in image_id_map.
|
"""Only recipes with valid CivitAI source_path appear in image_id_map.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user