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:
@@ -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"]
|
||||
|
||||
|
||||
@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):
|
||||
"""Only recipes with valid CivitAI source_path appear in image_id_map.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user