diff --git a/docs/frontend-filtering-test-matrix.md b/docs/frontend-filtering-test-matrix.md new file mode 100644 index 00000000..cd8f48d2 --- /dev/null +++ b/docs/frontend-filtering-test-matrix.md @@ -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. diff --git a/docs/frontend-testing-roadmap.md b/docs/frontend-testing-roadmap.md index e4b4de39..50025cb7 100644 --- a/docs/frontend-testing-roadmap.md +++ b/docs/frontend-testing-roadmap.md @@ -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-01–F-05 & F-09; queue remaining edge cases after duplicate/bulk flows stabilize. +- [x] Implement checkpoints manager filtering/sorting specs for scenarios F-01–F-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. diff --git a/tests/frontend/components/pageControls.filtering.test.js b/tests/frontend/components/pageControls.filtering.test.js new file mode 100644 index 00000000..bdbe4121 --- /dev/null +++ b/tests/frontend/components/pageControls.filtering.test.js @@ -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 = ` + + + +
+
+
+
+ +
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ + `; +} + +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); + }); +});