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 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 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 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 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 | | 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. - [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). - [ ] 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. - [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. - [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. 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);
});
});