mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-07 06:20:15 -03:00
feat(recipes): branch masonry scroller instantiation for recipes page
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { VirtualScroller } from './VirtualScroller.js';
|
||||
import { MasonryScroller } from './MasonryScroller.js';
|
||||
import { createModelCard, setupModelCardEventDelegation } from '../components/shared/ModelCard.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { showToast } from './uiHelpers.js';
|
||||
@@ -141,8 +142,14 @@ async function initializeVirtualScroll(pageType) {
|
||||
throw new Error(`Required components not available for ${pageType} page`);
|
||||
}
|
||||
|
||||
// Masonry applies to the recipes page only; the read pattern mirrors
|
||||
// VirtualScroller.js (display_density).
|
||||
const useMasonry = pageType === 'recipes'
|
||||
&& (state.global.settings?.recipes_layout ?? 'grid') === 'masonry';
|
||||
const ScrollerClass = useMasonry ? MasonryScroller : VirtualScroller;
|
||||
|
||||
// Initialize virtual scroller with renamed container elements
|
||||
state.virtualScroller = new VirtualScroller({
|
||||
state.virtualScroller = new ScrollerClass({
|
||||
gridElement: grid,
|
||||
containerElement: gridContainer,
|
||||
scrollContainer: scrollContainer,
|
||||
@@ -250,3 +257,19 @@ export async function refreshVirtualScroll(options = {}) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild the virtual scroller from scratch (used for layout switching).
|
||||
// refreshVirtualScroll only resets the existing instance, so it cannot swap
|
||||
// the scroller class. Keyboard navigation must be cleaned up first:
|
||||
// setupKeyboardNavigation appends a new document keydown listener on every
|
||||
// initializeVirtualScroll call and never removes the previous one.
|
||||
export async function recreateVirtualScroll(pageType) {
|
||||
cleanupKeyboardNavigation();
|
||||
|
||||
if (state.virtualScroller) {
|
||||
state.virtualScroller.dispose();
|
||||
state.virtualScroller = null;
|
||||
}
|
||||
|
||||
await initializeVirtualScroll(pageType);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ vi.mock('../../../static/js/state/index.js', () => {
|
||||
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
||||
captureScrollPosition: captureScrollPositionMock,
|
||||
restoreScrollPosition: restoreScrollPositionMock,
|
||||
recreateVirtualScroll: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
|
||||
@@ -92,6 +92,7 @@ vi.mock('../../../static/js/utils/eventManagementInit.js', () => ({
|
||||
|
||||
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
||||
initializeInfiniteScroll: vi.fn(),
|
||||
recreateVirtualScroll: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({
|
||||
|
||||
@@ -69,6 +69,7 @@ vi.mock('../../../static/js/components/DuplicatesManager.js', () => ({
|
||||
|
||||
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
||||
refreshVirtualScroll: refreshVirtualScrollMock,
|
||||
recreateVirtualScroll: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||
|
||||
198
tests/frontend/utils/infiniteScroll.test.js
Normal file
198
tests/frontend/utils/infiniteScroll.test.js
Normal file
@@ -0,0 +1,198 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
VirtualScrollerMock,
|
||||
MasonryScrollerMock,
|
||||
RecipeCardMock,
|
||||
fetchRecipesPageMock,
|
||||
getModelApiClientMock,
|
||||
} = vi.hoisted(() => {
|
||||
const makeScrollerClass = () => vi.fn(function (options) {
|
||||
this.options = options;
|
||||
this.initialize = vi.fn(async () => {});
|
||||
this.dispose = vi.fn();
|
||||
this.reset = vi.fn();
|
||||
this.handlePageUpDown = vi.fn();
|
||||
});
|
||||
|
||||
return {
|
||||
VirtualScrollerMock: makeScrollerClass(),
|
||||
MasonryScrollerMock: makeScrollerClass(),
|
||||
RecipeCardMock: vi.fn(function () {
|
||||
this.element = document.createElement('div');
|
||||
}),
|
||||
fetchRecipesPageMock: vi.fn(async () => ({ items: [], totalItems: 0, hasMore: false })),
|
||||
getModelApiClientMock: vi.fn(() => ({
|
||||
fetchModelsPage: vi.fn(async () => ({ items: [], totalItems: 0, hasMore: false })),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../static/js/utils/VirtualScroller.js', () => ({
|
||||
VirtualScroller: VirtualScrollerMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/MasonryScroller.js', () => ({
|
||||
MasonryScroller: MasonryScrollerMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/RecipeCard.js', () => ({
|
||||
RecipeCard: RecipeCardMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||
fetchRecipesPage: fetchRecipesPageMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||
getModelApiClient: getModelApiClientMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
|
||||
createModelCard: vi.fn(() => document.createElement('div')),
|
||||
setupModelCardEventDelegation: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
initializeInfiniteScroll,
|
||||
recreateVirtualScroll,
|
||||
} from '../../../static/js/utils/infiniteScroll.js';
|
||||
import { state } from '../../../static/js/state/index.js';
|
||||
|
||||
function setupPageDom() {
|
||||
const pageContent = document.createElement('div');
|
||||
pageContent.className = 'page-content';
|
||||
const container = document.createElement('div');
|
||||
container.className = 'container';
|
||||
pageContent.appendChild(container);
|
||||
document.body.appendChild(pageContent);
|
||||
|
||||
const grid = document.createElement('div');
|
||||
vi.spyOn(document, 'getElementById').mockImplementation((id) =>
|
||||
id === 'recipeGrid' || id === 'modelGrid' ? grid : null);
|
||||
|
||||
return { grid };
|
||||
}
|
||||
|
||||
describe('infiniteScroll scroller class branching', () => {
|
||||
let originalSettings;
|
||||
|
||||
beforeEach(() => {
|
||||
originalSettings = state.global.settings;
|
||||
state.global.settings = { ...originalSettings };
|
||||
if (state.pages.recipes) {
|
||||
state.pages.recipes.duplicatesMode = false;
|
||||
}
|
||||
state.virtualScroller = null;
|
||||
state.keyboardNavHandler = null;
|
||||
setupPageDom();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
state.global.settings = originalSettings;
|
||||
state.virtualScroller = null;
|
||||
state.keyboardNavHandler = null;
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('constructs MasonryScroller for the recipes page when recipes_layout is masonry', async () => {
|
||||
state.global.settings.recipes_layout = 'masonry';
|
||||
|
||||
await initializeInfiniteScroll('recipes');
|
||||
|
||||
expect(MasonryScrollerMock).toHaveBeenCalledTimes(1);
|
||||
expect(VirtualScrollerMock).not.toHaveBeenCalled();
|
||||
expect(state.virtualScroller).toBeInstanceOf(MasonryScrollerMock);
|
||||
});
|
||||
|
||||
it('constructs VirtualScroller for the recipes page when recipes_layout is grid', async () => {
|
||||
state.global.settings.recipes_layout = 'grid';
|
||||
|
||||
await initializeInfiniteScroll('recipes');
|
||||
|
||||
expect(VirtualScrollerMock).toHaveBeenCalledTimes(1);
|
||||
expect(MasonryScrollerMock).not.toHaveBeenCalled();
|
||||
expect(state.virtualScroller).toBeInstanceOf(VirtualScrollerMock);
|
||||
});
|
||||
|
||||
it('falls back to the grid branch when recipes_layout is missing', async () => {
|
||||
delete state.global.settings.recipes_layout;
|
||||
|
||||
await expect(initializeInfiniteScroll('recipes')).resolves.toBeUndefined();
|
||||
|
||||
expect(VirtualScrollerMock).toHaveBeenCalledTimes(1);
|
||||
expect(MasonryScrollerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('always constructs VirtualScroller for the loras page regardless of recipes_layout', async () => {
|
||||
state.global.settings.recipes_layout = 'masonry';
|
||||
|
||||
await initializeInfiniteScroll('loras');
|
||||
|
||||
expect(VirtualScrollerMock).toHaveBeenCalledTimes(1);
|
||||
expect(MasonryScrollerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('recreateVirtualScroll', () => {
|
||||
let originalSettings;
|
||||
|
||||
beforeEach(() => {
|
||||
originalSettings = state.global.settings;
|
||||
state.global.settings = { ...originalSettings };
|
||||
if (state.pages.recipes) {
|
||||
state.pages.recipes.duplicatesMode = false;
|
||||
}
|
||||
state.virtualScroller = null;
|
||||
state.keyboardNavHandler = null;
|
||||
setupPageDom();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
state.global.settings = originalSettings;
|
||||
state.virtualScroller = null;
|
||||
state.keyboardNavHandler = null;
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('cleans up keyboard navigation, disposes the old scroller, and rebuilds with the new layout', async () => {
|
||||
state.global.settings.recipes_layout = 'grid';
|
||||
await initializeInfiniteScroll('recipes');
|
||||
const oldScroller = state.virtualScroller;
|
||||
const oldKeyboardHandler = state.keyboardNavHandler;
|
||||
expect(oldKeyboardHandler).toBeTypeOf('function');
|
||||
|
||||
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
|
||||
state.global.settings.recipes_layout = 'masonry';
|
||||
|
||||
await recreateVirtualScroll('recipes');
|
||||
|
||||
// cleanupKeyboardNavigation removed the previous document keydown listener
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', oldKeyboardHandler);
|
||||
// Old scroller disposed and replaced by a new masonry instance
|
||||
expect(oldScroller.dispose).toHaveBeenCalledTimes(1);
|
||||
expect(MasonryScrollerMock).toHaveBeenCalledTimes(1);
|
||||
expect(state.virtualScroller).toBeInstanceOf(MasonryScrollerMock);
|
||||
expect(state.virtualScroller).not.toBe(oldScroller);
|
||||
// A fresh keyboard navigation listener was registered for the new instance
|
||||
expect(state.keyboardNavHandler).toBeTypeOf('function');
|
||||
expect(state.keyboardNavHandler).not.toBe(oldKeyboardHandler);
|
||||
});
|
||||
|
||||
it('works when there is no existing virtual scroller', async () => {
|
||||
state.global.settings.recipes_layout = 'masonry';
|
||||
|
||||
await expect(recreateVirtualScroll('recipes')).resolves.toBeUndefined();
|
||||
|
||||
expect(MasonryScrollerMock).toHaveBeenCalledTimes(1);
|
||||
expect(state.virtualScroller).toBeInstanceOf(MasonryScrollerMock);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user