mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-07 14:30:15 -03:00
feat(recipes): add MasonryScroller with column-based virtual scrolling
This commit is contained in:
306
tests/frontend/utils/masonryScroller.test.js
Normal file
306
tests/frontend/utils/masonryScroller.test.js
Normal file
@@ -0,0 +1,306 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MasonryScroller } from '../../../static/js/utils/MasonryScroller.js';
|
||||
import { getCurrentPageState, setCurrentPageType } from '../../../static/js/state/index.js';
|
||||
|
||||
// jsdom does not always provide requestAnimationFrame; polyfill when missing
|
||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame !== 'function') {
|
||||
window.requestAnimationFrame = (cb) => setTimeout(cb, 0);
|
||||
window.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||
}
|
||||
|
||||
const CONTAINER_WIDTH = 768; // yields 3 columns at default density: floor((768+12)/(240+12)) = 3
|
||||
const COLUMN_GAP = 12;
|
||||
const ROW_GAP = 20;
|
||||
const PAD_TOP = 4;
|
||||
const PAD_BOTTOM = 4;
|
||||
const ITEM_WIDTH = (CONTAINER_WIDTH - 2 * COLUMN_GAP) / 3; // 248
|
||||
const FALLBACK_HEIGHT = ITEM_WIDTH / (896 / 1152);
|
||||
|
||||
function createItemFn() {
|
||||
const el = document.createElement('div');
|
||||
const card = document.createElement('div');
|
||||
card.className = 'model-card';
|
||||
el.appendChild(card);
|
||||
return el;
|
||||
}
|
||||
|
||||
function makeItems(dimensions) {
|
||||
return dimensions.map((dims, i) => ({
|
||||
file_path: `/recipes/item-${i}.png`,
|
||||
...dims,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a scroller attached to a stubbed container. clientWidth/clientHeight
|
||||
* are 0 in jsdom, so they are defined explicitly for deterministic layout.
|
||||
*/
|
||||
function createScroller({ items = [], fetchItemsFn, overscan, viewportHeight = 600 } = {}) {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: CONTAINER_WIDTH, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientHeight', { value: viewportHeight, configurable: true });
|
||||
|
||||
const grid = document.createElement('div');
|
||||
wrapper.appendChild(grid);
|
||||
document.body.appendChild(wrapper);
|
||||
|
||||
const fetchMock = fetchItemsFn || vi.fn(async () => ({ items, totalItems: items.length, hasMore: false }));
|
||||
|
||||
const scroller = new MasonryScroller({
|
||||
gridElement: grid,
|
||||
containerElement: wrapper,
|
||||
scrollContainer: wrapper,
|
||||
createItemFn,
|
||||
fetchItemsFn: fetchMock,
|
||||
overscan,
|
||||
});
|
||||
|
||||
return { scroller, wrapper, grid, fetchMock };
|
||||
}
|
||||
|
||||
describe('MasonryScroller', () => {
|
||||
const liveScrollers = [];
|
||||
|
||||
beforeEach(() => {
|
||||
setCurrentPageType('recipes');
|
||||
getCurrentPageState().duplicatesMode = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (liveScrollers.length > 0) {
|
||||
liveScrollers.pop().dispose();
|
||||
}
|
||||
getCurrentPageState().duplicatesMode = false;
|
||||
});
|
||||
|
||||
function track(setup) {
|
||||
liveScrollers.push(setup.scroller);
|
||||
return setup;
|
||||
}
|
||||
|
||||
it('adds virtual-scroll and masonry-layout classes and creates the spacer', () => {
|
||||
const { scroller, grid } = track(createScroller());
|
||||
|
||||
expect(grid.classList.contains('virtual-scroll')).toBe(true);
|
||||
expect(grid.classList.contains('masonry-layout')).toBe(true);
|
||||
expect(scroller.spacerElement.className).toBe('virtual-scroll-spacer');
|
||||
expect(grid.style.position).toBe('relative');
|
||||
expect(grid.contains(scroller.spacerElement)).toBe(true);
|
||||
});
|
||||
|
||||
it('computes density-based column count and item width', () => {
|
||||
const { scroller } = track(createScroller());
|
||||
|
||||
expect(scroller.columnsCount).toBe(3);
|
||||
expect(scroller.itemWidth).toBeCloseTo(ITEM_WIDTH);
|
||||
});
|
||||
|
||||
it('places each item into the shortest column', () => {
|
||||
// Heights at ITEM_WIDTH=248: 496, 248, 124, 248, 248, 248
|
||||
const items = makeItems([
|
||||
{ width: 100, height: 200 },
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 50 },
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 100 },
|
||||
]);
|
||||
const { scroller } = track(createScroller({ items }));
|
||||
|
||||
scroller.refreshWithData(items, items.length, false);
|
||||
|
||||
const cols = scroller.positions.map((p) => p.col);
|
||||
expect(cols).toEqual([0, 1, 2, 2, 1, 2]);
|
||||
|
||||
// Tops follow the accumulated shortest-column heights
|
||||
expect(scroller.positions[0].top).toBeCloseTo(PAD_TOP);
|
||||
expect(scroller.positions[1].top).toBeCloseTo(PAD_TOP);
|
||||
expect(scroller.positions[2].top).toBeCloseTo(PAD_TOP);
|
||||
expect(scroller.positions[3].top).toBeCloseTo(PAD_TOP + 124 + ROW_GAP); // 148
|
||||
expect(scroller.positions[4].top).toBeCloseTo(PAD_TOP + 248 + ROW_GAP); // 272
|
||||
expect(scroller.positions[5].top).toBeCloseTo(148 + 248 + ROW_GAP); // 416
|
||||
|
||||
// Left offsets are column index * (itemWidth + columnGap)
|
||||
expect(scroller.positions[1].left).toBeCloseTo(ITEM_WIDTH + COLUMN_GAP);
|
||||
expect(scroller.positions[2].left).toBeCloseTo(2 * (ITEM_WIDTH + COLUMN_GAP));
|
||||
});
|
||||
|
||||
it('sets spacer height to the tallest column minus trailing gap plus padding', () => {
|
||||
const items = makeItems([
|
||||
{ width: 100, height: 200 },
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 50 },
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 100 },
|
||||
]);
|
||||
const { scroller } = track(createScroller({ items }));
|
||||
|
||||
scroller.refreshWithData(items, items.length, false);
|
||||
|
||||
// Column heights after placement: [520, 540, 684] (each starts at padTop)
|
||||
const expected = 684 - ROW_GAP + PAD_TOP + PAD_BOTTOM; // 672
|
||||
expect(scroller.spacerElement.style.height).toBe(`${expected}px`);
|
||||
});
|
||||
|
||||
it('falls back to the 896/1152 ratio for items without dimensions', () => {
|
||||
const items = makeItems([{}, { width: 100, height: 100 }]);
|
||||
const { scroller } = track(createScroller({ items }));
|
||||
|
||||
scroller.refreshWithData(items, items.length, false);
|
||||
|
||||
expect(scroller.positions[0].height).toBeCloseTo(FALLBACK_HEIGHT);
|
||||
expect(scroller.positions[1].height).toBeCloseTo(ITEM_WIDTH);
|
||||
});
|
||||
|
||||
it('visible range respects the overscan band', () => {
|
||||
const items = makeItems([
|
||||
{ width: 100, height: 200 },
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 50 },
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 100 },
|
||||
]);
|
||||
const { scroller, wrapper } = track(createScroller({ items, viewportHeight: 300 }));
|
||||
|
||||
scroller.refreshWithData(items, items.length, false);
|
||||
wrapper.scrollTop = 0;
|
||||
|
||||
// Without overscan, item 5 (top=416) is below the 300px viewport
|
||||
scroller.overscan = 0;
|
||||
let visible = scroller.getVisibleRange();
|
||||
expect([...visible].sort((a, b) => a - b)).toEqual([0, 1, 2, 3, 4]);
|
||||
|
||||
// overscan=1 extends the band by one itemWidth (248px), pulling item 5 in
|
||||
scroller.overscan = 1;
|
||||
visible = scroller.getVisibleRange();
|
||||
expect(visible.has(5)).toBe(true);
|
||||
});
|
||||
|
||||
it('renders visible items with inline masonry styles and clears model-card max-width', () => {
|
||||
const items = makeItems([
|
||||
{ width: 100, height: 200 },
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 50 },
|
||||
]);
|
||||
const { scroller, grid, wrapper } = track(createScroller({ items, viewportHeight: 3000 }));
|
||||
|
||||
scroller.refreshWithData(items, items.length, false);
|
||||
wrapper.scrollTop = 0;
|
||||
scroller.overscan = 5;
|
||||
|
||||
scroller.renderItems();
|
||||
|
||||
const rendered = grid.querySelectorAll('.virtual-scroll-item');
|
||||
expect(rendered.length).toBe(3);
|
||||
rendered.forEach((el, i) => {
|
||||
expect(el.style.position).toBe('absolute');
|
||||
expect(el.style.width).toBe(`${scroller.positions[i].width}px`);
|
||||
expect(el.style.height).toBe(`${scroller.positions[i].height}px`);
|
||||
expect(el.style.left).toBe(`${scroller.positions[i].left}px`);
|
||||
expect(el.style.top).toBe(`${scroller.positions[i].top}px`);
|
||||
expect(el.querySelector('.model-card').style.maxWidth).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
it('triggers loadMoreItems when scrolled to the bottom', async () => {
|
||||
const firstPage = makeItems([
|
||||
{ width: 100, height: 200 },
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 50 },
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 100 },
|
||||
]);
|
||||
const fetchMock = vi.fn(async () => ({ items: makeItems([{ width: 100, height: 100 }]), totalItems: 7, hasMore: false }));
|
||||
const { scroller, wrapper } = track(createScroller({ fetchItemsFn: fetchMock, viewportHeight: 600 }));
|
||||
|
||||
scroller.refreshWithData(firstPage, 100, true);
|
||||
const pageState = getCurrentPageState();
|
||||
const expectedPage = pageState.currentPage;
|
||||
|
||||
// contentBottom = 664; scrollBottom = 100 + 600 = 700 >= 664 - threshold
|
||||
wrapper.scrollTop = 100;
|
||||
scroller.handleScroll();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expectedPage, scroller.pageSize);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not trigger loadMoreItems when far from the bottom', async () => {
|
||||
const fetchMock = vi.fn(async () => ({ items: [], totalItems: 0, hasMore: false }));
|
||||
const manyItems = makeItems(Array.from({ length: 30 }, () => ({ width: 100, height: 200 })));
|
||||
const { scroller, wrapper } = track(createScroller({ fetchItemsFn: fetchMock, viewportHeight: 600 }));
|
||||
|
||||
scroller.refreshWithData(manyItems, 1000, true);
|
||||
wrapper.scrollTop = 0;
|
||||
scroller.handleScroll();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false from calculateLayout in duplicates mode', () => {
|
||||
const { scroller } = track(createScroller());
|
||||
|
||||
getCurrentPageState().duplicatesMode = true;
|
||||
expect(scroller.calculateLayout()).toBe(false);
|
||||
});
|
||||
|
||||
it('computes placement and spacer synchronously during initialize (before rAF)', async () => {
|
||||
const items = makeItems([
|
||||
{ width: 100, height: 200 },
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 50 },
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 100 },
|
||||
]);
|
||||
const { scroller } = track(createScroller({ items }));
|
||||
|
||||
await scroller.initialize();
|
||||
|
||||
// Assert immediately after initialize resolves, without waiting for rAF
|
||||
expect(scroller.positions.length).toBe(items.length);
|
||||
const expected = 684 - ROW_GAP + PAD_TOP + PAD_BOTTOM; // 672
|
||||
expect(scroller.spacerElement.style.height).toBe(`${expected}px`);
|
||||
expect(getCurrentPageState().currentPage).toBe(2);
|
||||
});
|
||||
|
||||
it('shows the error placeholder and resets isLoading when the initial fetch fails', async () => {
|
||||
const fetchMock = vi.fn(async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
const { scroller, grid } = track(createScroller({ fetchItemsFn: fetchMock }));
|
||||
|
||||
await expect(scroller.initialize()).resolves.toBeUndefined();
|
||||
|
||||
const placeholder = grid.querySelector('#virtualScrollPlaceholder');
|
||||
expect(placeholder).not.toBeNull();
|
||||
expect(placeholder.textContent).toContain('Failed to load items');
|
||||
expect(scroller.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the recipes empty placeholder when no items are returned', async () => {
|
||||
const { scroller, grid } = track(createScroller({ items: [] }));
|
||||
|
||||
await scroller.initialize();
|
||||
|
||||
const placeholder = grid.querySelector('#virtualScrollPlaceholder');
|
||||
expect(placeholder).not.toBeNull();
|
||||
expect(placeholder.textContent).toContain('No recipes found');
|
||||
});
|
||||
|
||||
it('dispose removes classes, spacer and event listeners', () => {
|
||||
const { scroller, grid } = track(createScroller());
|
||||
|
||||
scroller.dispose();
|
||||
|
||||
expect(grid.classList.contains('virtual-scroll')).toBe(false);
|
||||
expect(grid.classList.contains('masonry-layout')).toBe(false);
|
||||
expect(grid.querySelector('.virtual-scroll-spacer')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user