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,44 @@
# LoRA & Checkpoints Filtering/Sorting Test Matrix
This matrix captures the scenarios that Phase 3 frontend tests should cover for the LoRA and Checkpoint managers. It focuses on how search, filter, sort, and duplicate badge toggles interact so future specs can share fixtures and expectations.
## Scope
- **Components**: `PageControls`, `FilterManager`, `SearchManager`, and `ModelDuplicatesManager` wiring invoked through `CheckpointsPageManager` and `LorasPageManager`.
- **Templates**: `templates/loras.html` and `templates/checkpoints.html` along with shared filter panel and toolbar partials.
- **APIs**: Requests issued through `baseModelApi.fetchModels` (via `resetAndReload`/`refreshModels`) and duplicates badge updates.
## Shared Setup Considerations
1. Render full page templates using `renderLorasPage` / `renderCheckpointsPage` helpers before importing modules so DOM queries resolve.
2. Stub storage helpers (`getStorageItem`, `setStorageItem`, `getSessionItem`, `setSessionItem`) to observe persistence behavior without mutating real storage.
3. Mock `sidebarManager` to capture refresh calls triggered after sort/filter actions.
4. Provide fake API implementations exposing `resetAndReload`, `refreshModels`, `fetchFromCivitai`, `toggleBulkMode`, and `clearCustomFilter` so control events remain asynchronous but deterministic.
5. Supply a minimal `ModelDuplicatesManager` mock exposing `toggleDuplicateMode`, `checkDuplicatesCount`, and `updateDuplicatesBadgeAfterRefresh` to validate duplicate badge wiring.
## Scenario Matrix
| ID | Feature | Scenario | LoRAs Expectations | Checkpoints Expectations | Notes |
| --- | --- | --- | --- | --- | --- |
| F-01 | Search filter | Typing a query updates `pageState.filters.search`, persists to session, and triggers `resetAndReload` on submit | Validate `SearchManager` writes query and reloads via API stub; confirm LoRA cards pass query downstream | Same as LoRAs | Cover `enter` press and clicking search icon |
| F-02 | Tag filter | Selecting a tag chip adds it to filters, applies active styling, and reloads results | Tag stored under `filters.tags`; `FilterManager.applyFilters` persists and triggers `resetAndReload(true)` | Same; ensure base model tag set is scoped to checkpoints dataset | Include removal path |
| F-03 | Base model filter | Toggling base model checkboxes updates `filters.baseModel`, persists, and reloads | Ensure only LoRA-supported models show; toggle multi-select | Ensure SDXL/Flux base models appear as expected | Capture UI state restored from storage on next init |
| F-04 | Favorites-only | Clicking favorites toggle updates session flag and calls `resetAndReload(true)` | Button gains `.active` class and API called | Same | Verify duplicates badge refresh when active |
| F-05 | Sort selection | Changing sort select saves preference (legacy + new format) and reloads | Confirm `PageControls.saveSortPreference` invoked with option and API called | Same with checkpoints-specific defaults | Cover `convertLegacySortFormat` branch |
| F-06 | Filter persistence | Re-initializing manager loads stored filters/sort and updates DOM | Filters pre-populate chips/checkboxes; favorites state restored | Same | Requires simulating repeated construction |
| F-07 | Combined filters | Applying search + tag + base model yields aggregated query params for fetch | Assert API receives merged filter payload | Same | Validate toast messaging for active filters |
| F-08 | Clearing filters | Using "Clear filters" resets state, storage, and reloads list | `FilterManager.clearFilters` empties `filters`, removes active class, shows toast | Same | Ensure favorites-only toggle unaffected |
| F-09 | Duplicate badge toggle | Pressing "Find duplicates" toggles duplicate mode and updates badge counts post-refresh | `ModelDuplicatesManager.toggleDuplicateMode` invoked and badge refresh called after API rebuild | Same plus checkpoint-specific duplicate badge dataset | Connects to future duplicate-specific specs |
| F-10 | Bulk actions menu | Opening bulk dropdown keeps filters intact and closes on outside click | Validate dropdown class toggling and no unintended reload | Same | Guard against regression when dropdown interacts with filters |
## Automation Coverage Status
- ✅ F-01 Search filter, F-02 Tag filter, F-03 Base model filter, F-04 Favorites-only toggle, F-05 Sort selection, and F-09 Duplicate badge toggle are covered by `tests/frontend/components/pageControls.filtering.test.js` for both LoRA and checkpoint pages.
- ⏳ F-06 Filter persistence, F-07 Combined filters, F-08 Clearing filters, and F-10 Bulk actions remain to be automated alongside upcoming bulk mode refinements.
## Coverage Gaps & Follow-Ups
- Write Vitest suites that exercise the matrix for both managers, sharing fixtures through page helpers to avoid duplication.
- Capture API parameter assertions by inspecting `baseModelApi.fetchModels` mocks rather than relying solely on state mutations.
- Add regression cases for legacy storage migrations (old filter keys) once fixtures exist for older payloads.
- Extend duplicate badge coverage with scenarios where `checkDuplicatesCount` signals zero duplicates versus pending calculations.

View File

@@ -9,7 +9,7 @@ This roadmap tracks the planned rollout of automated testing for the ComfyUI LoR
| Phase 0 | Establish baseline tooling | Add Node test runner, jsdom environment, and seed smoke tests | ✅ Complete | Vitest + jsdom configured, example state tests committed |
| Phase 1 | Cover state management logic | Unit test selectors, derived data helpers, and storage utilities under `static/js/state` and `static/js/utils` | ✅ Complete | Storage helpers and state selectors now exercised via deterministic suites |
| Phase 2 | Test AppCore orchestration | Simulate page bootstrapping, infinite scroll hooks, and manager registration using JSDOM DOM fixtures | ✅ Complete | AppCore initialization + page feature suites now validate manager wiring, infinite scroll hooks, and onboarding gating |
| Phase 3 | Validate page-specific managers | Add focused suites for `loras`, `checkpoints`, `embeddings`, and `recipes` managers covering filtering, sorting, and bulk actions | 🚧 In Progress | LoRA + checkpoints smoke suites landed; outlining filter/sort coverage before extending to embeddings |
| Phase 3 | Validate page-specific managers | Add focused suites for `loras`, `checkpoints`, `embeddings`, and `recipes` managers covering filtering, sorting, and bulk actions | 🚧 In Progress | LoRA + checkpoints smoke suites landed; filter/sort scenario matrix drafted to guide upcoming specs |
| Phase 4 | Interaction-level regression tests | Exercise template fragments, modals, and menus to ensure UI wiring remains intact | ⚪ Not Started | Evaluate Playwright component testing or happy-path DOM snapshots |
| Phase 5 | Continuous integration & coverage | Integrate frontend tests into CI workflow and track coverage metrics | ⚪ Not Started | Align reporting directories with backend coverage for unified reporting |
@@ -22,8 +22,11 @@ This roadmap tracks the planned rollout of automated testing for the ComfyUI LoR
- [x] Extend AppCore orchestration tests to cover manager wiring, bulk menu setup, and onboarding gating scenarios.
- [ ] Evaluate integrating coverage reporting once test surface grows (> 20 specs).
- [x] Create shared fixtures for the loras and checkpoints pages once dedicated manager suites are added.
- [ ] Draft focused test matrix for loras/checkpoints manager filtering and sorting paths ahead of Phase 3.
- [x] Draft focused test matrix for loras/checkpoints manager filtering and sorting paths ahead of Phase 3.
- [x] Implement LoRAs manager filtering/sorting specs for scenarios F-01F-05 & F-09; queue remaining edge cases after duplicate/bulk flows stabilize.
- [x] Implement checkpoints manager filtering/sorting specs for scenarios F-01F-05 & F-09; cover remaining paths alongside bulk action work.
- [x] Implement checkpoints page manager smoke tests covering initialization and duplicate badge wiring.
- [ ] Outline focused checkpoints scenarios (filtering, sorting, duplicate badge toggles) to feed into the shared test matrix.
- [x] Outline focused checkpoints scenarios (filtering, sorting, duplicate badge toggles) to feed into the shared test matrix.
- [ ] Add duplicate badge regression coverage for zero/pending states after API refreshes.
Maintaining this roadmap alongside code changes will make it easier to append new automated test tasks and update their progress.

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);
});
});