test(frontend): add filtering coverage for model pages

This commit is contained in:
pixelpaws
2025-09-24 17:50:04 +08:00
parent 4fb69f7d89
commit 39225dc204
3 changed files with 417 additions and 3 deletions

View File

@@ -0,0 +1,367 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const loadMoreWithVirtualScrollMock = vi.fn();
const refreshModelsMock = vi.fn();
const fetchCivitaiMetadataMock = vi.fn();
const resetAndReloadMock = vi.fn();
const getModelApiClientMock = vi.fn();
const apiClientMock = {
loadMoreWithVirtualScroll: loadMoreWithVirtualScrollMock,
refreshModels: refreshModelsMock,
fetchCivitaiMetadata: fetchCivitaiMetadataMock,
};
const showToastMock = vi.fn();
const updatePanelPositionsMock = vi.fn();
const downloadManagerMock = {
showDownloadModal: vi.fn(),
};
const sidebarManagerMock = {
initialize: vi.fn(async () => {
sidebarManagerMock.isInitialized = true;
}),
refresh: vi.fn(async () => {}),
cleanup: vi.fn(),
isInitialized: false,
};
const createAlphabetBarMock = vi.fn(() => ({ destroy: vi.fn() }));
getModelApiClientMock.mockReturnValue(apiClientMock);
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
getModelApiClient: getModelApiClientMock,
resetAndReload: resetAndReloadMock,
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
updatePanelPositions: updatePanelPositionsMock,
}));
vi.mock('../../../static/js/managers/DownloadManager.js', () => ({
downloadManager: downloadManagerMock,
}));
vi.mock('../../../static/js/components/SidebarManager.js', () => ({
sidebarManager: sidebarManagerMock,
}));
vi.mock('../../../static/js/components/alphabet/index.js', () => ({
createAlphabetBar: createAlphabetBarMock,
}));
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
loadMoreWithVirtualScrollMock.mockResolvedValue(undefined);
refreshModelsMock.mockResolvedValue(undefined);
fetchCivitaiMetadataMock.mockResolvedValue(undefined);
resetAndReloadMock.mockResolvedValue(undefined);
getModelApiClientMock.mockReturnValue(apiClientMock);
sidebarManagerMock.isInitialized = false;
sidebarManagerMock.initialize.mockImplementation(async () => {
sidebarManagerMock.isInitialized = true;
});
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, base_models: [] }),
});
});
afterEach(() => {
delete window.modelDuplicatesManager;
delete global.fetch;
vi.useRealTimers();
});
function renderControlsDom(pageKey) {
document.body.dataset.page = pageKey;
document.body.innerHTML = `
<div class="header-search">
<div class="search-container">
<input id="searchInput" />
<i class="fas fa-search search-icon"></i>
<button id="searchOptionsToggle" class="search-options-toggle"></button>
<button id="filterButton" class="search-filter-toggle">
<span id="activeFiltersCount" class="filter-badge" style="display: none">0</span>
</button>
</div>
</div>
<div id="searchOptionsPanel" class="search-options-panel hidden">
<button id="closeSearchOptions"></button>
<div class="search-option-tag active" data-option="filename"></div>
</div>
<div id="filterPanel" class="filter-panel hidden">
<div id="baseModelTags" class="filter-tags"></div>
<div id="modelTagsFilter" class="filter-tags"></div>
<button class="clear-filter"></button>
</div>
<div class="controls">
<div class="actions">
<div class="action-buttons">
<div class="control-group">
<select id="sortSelect">
<option value="name:asc">Name Asc</option>
<option value="name:desc">Name Desc</option>
<option value="date:desc">Date Desc</option>
<option value="date:asc">Date Asc</option>
</select>
</div>
<div class="control-group dropdown-group">
<button data-action="refresh" class="dropdown-main"></button>
<button class="dropdown-toggle"></button>
<div class="dropdown-menu">
<div class="dropdown-item" data-action="quick-refresh"></div>
<div class="dropdown-item" data-action="full-rebuild"></div>
</div>
</div>
<div class="control-group">
<button data-action="fetch"></button>
</div>
<div class="control-group">
<button data-action="download"></button>
</div>
<div class="control-group">
<button data-action="bulk"></button>
</div>
<div class="control-group">
<button data-action="find-duplicates"></button>
</div>
<div class="control-group">
<button id="favoriteFilterBtn" class="favorite-filter"></button>
</div>
</div>
</div>
</div>
<div id="customFilterIndicator" class="control-group hidden">
<div class="filter-active">
<i class="fas fa-times-circle clear-filter"></i>
</div>
</div>
`;
}
describe('SearchManager filtering scenarios', () => {
it.each([
['loras'],
['checkpoints'],
])('updates filters and reloads results for %s page', async (pageKey) => {
vi.useFakeTimers();
renderControlsDom(pageKey);
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState(pageKey);
const { getCurrentPageState } = stateModule;
const { SearchManager } = await import('../../../static/js/managers/SearchManager.js');
new SearchManager({ page: pageKey, searchDelay: 0 });
const input = document.getElementById('searchInput');
input.value = 'flux';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
expect(getCurrentPageState().filters.search).toBe('flux');
expect(loadMoreWithVirtualScrollMock).toHaveBeenCalledWith(true, false);
expect(loadMoreWithVirtualScrollMock).toHaveBeenCalledTimes(1);
});
});
describe('FilterManager tag and base model filters', () => {
it.each([
['loras'],
['checkpoints'],
])('toggles tag chips and persists filters for %s page', async (pageKey) => {
renderControlsDom(pageKey);
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState(pageKey);
const { getCurrentPageState } = stateModule;
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
const manager = new FilterManager({ page: pageKey });
manager.createTagFilterElements([{ tag: 'style', count: 5 }]);
const tagChip = document.querySelector('.filter-tag.tag-filter');
expect(tagChip).not.toBeNull();
tagChip.dispatchEvent(new Event('click', { bubbles: true }));
await vi.waitFor(() => expect(loadMoreWithVirtualScrollMock).toHaveBeenCalledTimes(1));
expect(getCurrentPageState().filters.tags).toEqual(['style']);
expect(tagChip.classList.contains('active')).toBe(true);
expect(document.getElementById('activeFiltersCount').textContent).toBe('1');
expect(document.getElementById('activeFiltersCount').style.display).toBe('inline-flex');
const storageKey = `lora_manager_${pageKey}_filters`;
const storedFilters = JSON.parse(localStorage.getItem(storageKey));
expect(storedFilters.tags).toEqual(['style']);
loadMoreWithVirtualScrollMock.mockClear();
tagChip.dispatchEvent(new Event('click', { bubbles: true }));
await vi.waitFor(() => expect(loadMoreWithVirtualScrollMock).toHaveBeenCalledTimes(1));
expect(getCurrentPageState().filters.tags).toEqual([]);
expect(document.getElementById('activeFiltersCount').style.display).toBe('none');
});
it.each([
['loras'],
['checkpoints'],
])('toggles base model chips and reloads %s results', async (pageKey) => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
base_models: [{ name: 'SDXL', count: 2 }],
}),
});
renderControlsDom(pageKey);
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState(pageKey);
const { getCurrentPageState } = stateModule;
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
const manager = new FilterManager({ page: pageKey });
await vi.waitFor(() => {
const chip = document.querySelector('[data-base-model="SDXL"]');
expect(chip).not.toBeNull();
});
const baseModelChip = document.querySelector('[data-base-model="SDXL"]');
baseModelChip.dispatchEvent(new Event('click', { bubbles: true }));
await vi.waitFor(() => expect(loadMoreWithVirtualScrollMock).toHaveBeenCalledTimes(1));
expect(getCurrentPageState().filters.baseModel).toEqual(['SDXL']);
expect(baseModelChip.classList.contains('active')).toBe(true);
const storageKey = `lora_manager_${pageKey}_filters`;
const storedFilters = JSON.parse(localStorage.getItem(storageKey));
expect(storedFilters.baseModel).toEqual(['SDXL']);
loadMoreWithVirtualScrollMock.mockClear();
baseModelChip.dispatchEvent(new Event('click', { bubbles: true }));
await vi.waitFor(() => expect(loadMoreWithVirtualScrollMock).toHaveBeenCalledTimes(1));
expect(getCurrentPageState().filters.baseModel).toEqual([]);
expect(baseModelChip.classList.contains('active')).toBe(false);
});
});
describe('PageControls favorites, sorting, and duplicates scenarios', () => {
it('persists favorites toggle for LoRAs and triggers reload', async () => {
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { LorasControls } = await import('../../../static/js/components/controls/LorasControls.js');
const controls = new LorasControls();
await controls.toggleFavoritesOnly();
expect(sessionStorage.getItem('lora_manager_show_favorites_only_loras')).toBe('true');
expect(stateModule.getCurrentPageState().showFavoritesOnly).toBe(true);
expect(document.getElementById('favoriteFilterBtn').classList.contains('active')).toBe(true);
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
resetAndReloadMock.mockClear();
await controls.toggleFavoritesOnly();
expect(sessionStorage.getItem('lora_manager_show_favorites_only_loras')).toBe('false');
expect(stateModule.getCurrentPageState().showFavoritesOnly).toBe(false);
expect(document.getElementById('favoriteFilterBtn').classList.contains('active')).toBe(false);
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
});
it('persists favorites toggle for checkpoints and triggers reload', async () => {
renderControlsDom('checkpoints');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('checkpoints');
const { CheckpointsControls } = await import('../../../static/js/components/controls/CheckpointsControls.js');
const controls = new CheckpointsControls();
await controls.toggleFavoritesOnly();
expect(sessionStorage.getItem('lora_manager_show_favorites_only_checkpoints')).toBe('true');
expect(stateModule.getCurrentPageState().showFavoritesOnly).toBe(true);
expect(document.getElementById('favoriteFilterBtn').classList.contains('active')).toBe(true);
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
resetAndReloadMock.mockClear();
await controls.toggleFavoritesOnly();
expect(sessionStorage.getItem('lora_manager_show_favorites_only_checkpoints')).toBe('false');
expect(stateModule.getCurrentPageState().showFavoritesOnly).toBe(false);
expect(document.getElementById('favoriteFilterBtn').classList.contains('active')).toBe(false);
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
});
it('saves sort selection and reloads models', async () => {
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { LorasControls } = await import('../../../static/js/components/controls/LorasControls.js');
new LorasControls();
const sortSelect = document.getElementById('sortSelect');
sortSelect.value = 'date:asc';
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await vi.waitFor(() => expect(resetAndReloadMock).toHaveBeenCalledTimes(1));
expect(localStorage.getItem('lora_manager_loras_sort')).toBe('date:asc');
expect(stateModule.getCurrentPageState().sortBy).toBe('date:asc');
});
it('converts legacy sort preference on initialization', async () => {
localStorage.setItem('loras_sort', 'date');
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { LorasControls } = await import('../../../static/js/components/controls/LorasControls.js');
new LorasControls();
const sortSelect = document.getElementById('sortSelect');
expect(sortSelect.value).toBe('date:desc');
expect(stateModule.getCurrentPageState().sortBy).toBe('date:desc');
});
it('updates duplicate badge after refresh and toggles duplicate mode from controls', async () => {
renderControlsDom('checkpoints');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('checkpoints');
const { CheckpointsControls } = await import('../../../static/js/components/controls/CheckpointsControls.js');
const controls = new CheckpointsControls();
const toggleDuplicateMode = vi.fn();
const updateDuplicatesBadgeAfterRefresh = vi.fn();
window.modelDuplicatesManager = {
toggleDuplicateMode,
updateDuplicatesBadgeAfterRefresh,
};
await controls.refreshModels(true);
expect(refreshModelsMock).toHaveBeenCalledWith(true);
expect(updateDuplicatesBadgeAfterRefresh).toHaveBeenCalledTimes(1);
const duplicateButton = document.querySelector('[data-action="find-duplicates"]');
duplicateButton.click();
expect(toggleDuplicateMode).toHaveBeenCalledTimes(1);
});
});