mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
Compare commits
2 Commits
08895f77ff
...
4ed9f775f6
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ed9f775f6 | |||
| 0b08ad283a |
@@ -240,9 +240,12 @@ class RecipeCard {
|
||||
|
||||
// Recipe card click event - only attach if not in duplicates mode
|
||||
if (!isDuplicatesMode) {
|
||||
card.addEventListener('click', () => {
|
||||
card.addEventListener('click', (e) => {
|
||||
if (state.bulkMode) {
|
||||
bulkManager.toggleCardSelection(card);
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
}
|
||||
bulkManager.toggleCardSelection(card, e.shiftKey);
|
||||
return;
|
||||
}
|
||||
this.clickHandler(this.recipe);
|
||||
|
||||
@@ -108,7 +108,10 @@ function handleModelCardEvent_internal(event, modelType) {
|
||||
}
|
||||
|
||||
// If no specific element was clicked, handle the card click (show modal or toggle selection)
|
||||
handleCardClick(card, modelType);
|
||||
if (state.bulkMode && event.shiftKey) {
|
||||
event.preventDefault(); // keep shift+click from extending a text selection
|
||||
}
|
||||
handleCardClick(card, modelType, event.shiftKey);
|
||||
return false; // Continue with other handlers (e.g., bulk selection)
|
||||
}
|
||||
|
||||
@@ -288,12 +291,12 @@ function handleViewLocalVersionsFromCard(card, modelType) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleCardClick(card, modelType) {
|
||||
function handleCardClick(card, modelType, extendSelection = false) {
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
if (state.bulkMode) {
|
||||
// Toggle selection using the bulk manager
|
||||
bulkManager.toggleCardSelection(card);
|
||||
bulkManager.toggleCardSelection(card, extendSelection);
|
||||
} else if (pageState && pageState.duplicatesMode) {
|
||||
// In duplicates mode, don't open modal when clicking cards
|
||||
return;
|
||||
@@ -543,8 +546,9 @@ export function createModelCard(model, modelType) {
|
||||
card.classList.add('excluded-model');
|
||||
}
|
||||
|
||||
// Apply selection state if in bulk mode and this card is in the selected set (LoRA only)
|
||||
if (modelType === MODEL_TYPES.LORA && state.bulkMode && state.selectedLoras.has(model.file_path)) {
|
||||
// state.selectedModels resolves to the active page's set (selectedLoras
|
||||
// included) - do not narrow this back to selectedLoras/LORA-only.
|
||||
if (state.bulkMode && state.selectedModels.has(model.file_path)) {
|
||||
card.classList.add('selected');
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ export class BulkManager {
|
||||
this.marqueeElement = null;
|
||||
this.initialSelectedModels = new Set();
|
||||
|
||||
// Shift+click range anchor: last plain-clicked filepath. Set in
|
||||
// toggleCardSelection, cleared in clearSelection.
|
||||
this.bulkAnchorFilepath = null;
|
||||
|
||||
// Drag detection properties
|
||||
this.dragThreshold = 5; // Pixels to move before considering it a drag
|
||||
this.dragDelayMs = 100; // Minimum hold time before a drag is treated as a marquee
|
||||
@@ -351,6 +355,7 @@ export class BulkManager {
|
||||
card.classList.remove('selected');
|
||||
});
|
||||
state.selectedModels.clear();
|
||||
this.bulkAnchorFilepath = null;
|
||||
|
||||
// Update context menu header if visible
|
||||
if (this.bulkContextMenu) {
|
||||
@@ -358,9 +363,13 @@ export class BulkManager {
|
||||
}
|
||||
}
|
||||
|
||||
toggleCardSelection(card) {
|
||||
toggleCardSelection(card, extendSelection = false) {
|
||||
const filepath = card.dataset.filepath;
|
||||
|
||||
if (extendSelection && this.selectRangeFromAnchor(filepath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (card.classList.contains('selected')) {
|
||||
card.classList.remove('selected');
|
||||
state.selectedModels.delete(filepath);
|
||||
@@ -372,12 +381,78 @@ export class BulkManager {
|
||||
this.updateMetadataCacheFromCard(filepath, card);
|
||||
}
|
||||
|
||||
this.bulkAnchorFilepath = filepath;
|
||||
|
||||
// Update context menu header if visible
|
||||
if (this.bulkContextMenu) {
|
||||
this.bulkContextMenu.updateSelectedCountHeader();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select exactly the items between the shift anchor and the target
|
||||
* (inclusive), following list order. Explorer-style range semantics:
|
||||
* selections outside the new range are dropped, and consecutive shifts
|
||||
* re-derive the range from the same anchor. Returns false when there is
|
||||
* no usable anchor so the caller can fall back to a single-card toggle.
|
||||
*/
|
||||
selectRangeFromAnchor(targetFilepath) {
|
||||
const scroller = state.virtualScroller;
|
||||
if (!scroller || !scroller.items || !this.bulkAnchorFilepath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const anchorIndex = scroller.findIndexByFilePath(this.bulkAnchorFilepath);
|
||||
const targetIndex = scroller.findIndexByFilePath(targetFilepath);
|
||||
if (anchorIndex === -1 || targetIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startIndex = Math.min(anchorIndex, targetIndex);
|
||||
const endIndex = Math.max(anchorIndex, targetIndex);
|
||||
const metadataCache = this.getMetadataCache();
|
||||
const rangePaths = new Set();
|
||||
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
const item = scroller.items[i];
|
||||
if (!item || !item.file_path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
rangePaths.add(item.file_path);
|
||||
|
||||
if (!metadataCache.has(item.file_path)) {
|
||||
const modelId = this.parseModelId(item?.civitai?.modelId);
|
||||
metadataCache.set(item.file_path, {
|
||||
fileName: item.file_name,
|
||||
folder: item.folder || '',
|
||||
usageTips: item.usage_tips || '{}',
|
||||
modelName: item.name || item.file_name,
|
||||
...(modelId !== null ? { modelId } : {})
|
||||
});
|
||||
}
|
||||
|
||||
state.selectedModels.add(item.file_path);
|
||||
}
|
||||
|
||||
for (const filepath of [...state.selectedModels]) {
|
||||
if (!rangePaths.has(filepath)) {
|
||||
state.selectedModels.delete(filepath);
|
||||
}
|
||||
}
|
||||
this.applySelectionState();
|
||||
|
||||
if (this.bulkContextMenu) {
|
||||
this.bulkContextMenu.updateSelectedCountHeader();
|
||||
}
|
||||
|
||||
if (this.isStripVisible) {
|
||||
this.updateThumbnailStrip();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getMetadataCache() {
|
||||
const currentType = state.currentPageType;
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
@@ -544,6 +544,12 @@ export class MasonryScroller {
|
||||
modelCard.style.minWidth = '0';
|
||||
}
|
||||
|
||||
// Scroll recycling rebuilds cards from scratch, dropping `.selected`
|
||||
// even though the filepath is still selected in state.
|
||||
if (state.bulkMode && modelCard && state.selectedModels.has(modelCard.dataset.filepath)) {
|
||||
modelCard.classList.add('selected');
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
|
||||
@@ -497,6 +497,12 @@ export class VirtualScroller {
|
||||
modelCard.style.minWidth = '0';
|
||||
}
|
||||
|
||||
// Scroll recycling rebuilds cards from scratch, dropping `.selected`
|
||||
// even though the filepath is still selected in state.
|
||||
if (state.bulkMode && modelCard && state.selectedModels.has(modelCard.dataset.filepath)) {
|
||||
modelCard.classList.add('selected');
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, it, beforeEach, afterEach, expect } from 'vitest';
|
||||
import { state } from '../../../static/js/state/index.js';
|
||||
import { MODEL_TYPES } from '../../../static/js/api/apiConfig.js';
|
||||
import { eventManager } from '../../../static/js/utils/EventManager.js';
|
||||
import { BulkManager } from '../../../static/js/managers/BulkManager.js';
|
||||
|
||||
function createCard(filePath) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'model-card';
|
||||
card.dataset.filepath = filePath;
|
||||
document.body.appendChild(card);
|
||||
return card;
|
||||
}
|
||||
|
||||
describe('BulkManager shift+click range selection', () => {
|
||||
let bulk;
|
||||
|
||||
beforeEach(() => {
|
||||
eventManager.cleanup();
|
||||
state.currentPageType = MODEL_TYPES.LORA;
|
||||
state.bulkMode = true;
|
||||
state.selectedModels.clear();
|
||||
state.loraMetadataCache.clear();
|
||||
document.body.innerHTML = '';
|
||||
|
||||
bulk = new BulkManager();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
eventManager.cleanup();
|
||||
delete state.virtualScroller;
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
function attachFakeScroller(filePaths) {
|
||||
const items = filePaths.map((fp) => ({
|
||||
file_path: fp,
|
||||
file_name: fp.split('/').pop(),
|
||||
folder: '',
|
||||
usage_tips: '{}',
|
||||
}));
|
||||
state.virtualScroller = {
|
||||
items,
|
||||
findIndexByFilePath: (fp) => items.findIndex((item) => item.file_path === fp),
|
||||
};
|
||||
return items;
|
||||
}
|
||||
|
||||
it('a plain click selects a single card and sets it as the shift anchor', () => {
|
||||
const cardA = createCard('/models/a.safetensors');
|
||||
|
||||
bulk.toggleCardSelection(cardA);
|
||||
|
||||
expect(state.selectedModels.has('/models/a.safetensors')).toBe(true);
|
||||
expect(bulk.bulkAnchorFilepath).toBe('/models/a.safetensors');
|
||||
});
|
||||
|
||||
it('shift+click selects every item between anchor and target', () => {
|
||||
attachFakeScroller(['/models/a', '/models/b', '/models/c', '/models/d']);
|
||||
const cards = ['/models/a', '/models/b', '/models/c', '/models/d'].map(createCard);
|
||||
|
||||
bulk.toggleCardSelection(cards[0]);
|
||||
bulk.toggleCardSelection(cards[3], true);
|
||||
|
||||
expect([...state.selectedModels].sort()).toEqual(['/models/a', '/models/b', '/models/c', '/models/d']);
|
||||
cards.forEach((card) => expect(card.classList.contains('selected')).toBe(true));
|
||||
// Anchor stays on the original plain-clicked card
|
||||
expect(bulk.bulkAnchorFilepath).toBe('/models/a');
|
||||
});
|
||||
|
||||
it('shift+click works when the target precedes the anchor', () => {
|
||||
attachFakeScroller(['/models/a', '/models/b', '/models/c', '/models/d']);
|
||||
const cards = ['/models/a', '/models/b', '/models/c', '/models/d'].map(createCard);
|
||||
|
||||
bulk.toggleCardSelection(cards[3]);
|
||||
bulk.toggleCardSelection(cards[1], true);
|
||||
|
||||
expect([...state.selectedModels].sort()).toEqual(['/models/b', '/models/c', '/models/d']);
|
||||
});
|
||||
|
||||
it('consecutive shift+clicks re-derive the range from the same anchor', () => {
|
||||
attachFakeScroller(['/models/a', '/models/b', '/models/c', '/models/d', '/models/e']);
|
||||
const cards = ['/models/a', '/models/b', '/models/c', '/models/d', '/models/e'].map(createCard);
|
||||
|
||||
bulk.toggleCardSelection(cards[0]);
|
||||
bulk.toggleCardSelection(cards[2], true);
|
||||
expect([...state.selectedModels].sort()).toEqual(['/models/a', '/models/b', '/models/c']);
|
||||
|
||||
bulk.toggleCardSelection(cards[4], true);
|
||||
expect([...state.selectedModels].sort()).toEqual([
|
||||
'/models/a', '/models/b', '/models/c', '/models/d', '/models/e',
|
||||
]);
|
||||
|
||||
bulk.toggleCardSelection(cards[1], true);
|
||||
expect([...state.selectedModels].sort()).toEqual(['/models/a', '/models/b']);
|
||||
expect(bulk.bulkAnchorFilepath).toBe('/models/a');
|
||||
});
|
||||
|
||||
it('shift+click drops selections that fall outside the new range', () => {
|
||||
attachFakeScroller(['/models/a', '/models/b', '/models/c', '/models/d', '/models/e']);
|
||||
const cards = ['/models/a', '/models/b', '/models/c', '/models/d', '/models/e'].map(createCard);
|
||||
|
||||
bulk.toggleCardSelection(cards[0]);
|
||||
|
||||
// Simulate an out-of-range selection (e.g. from a previous marquee)
|
||||
state.selectedModels.add('/models/e');
|
||||
cards[4].classList.add('selected');
|
||||
|
||||
bulk.toggleCardSelection(cards[2], true);
|
||||
|
||||
expect([...state.selectedModels].sort()).toEqual(['/models/a', '/models/b', '/models/c']);
|
||||
expect(cards[4].classList.contains('selected')).toBe(false);
|
||||
expect(bulk.bulkAnchorFilepath).toBe('/models/a');
|
||||
});
|
||||
|
||||
it('shift+click without an anchor falls back to a plain toggle', () => {
|
||||
attachFakeScroller(['/models/a', '/models/b', '/models/c']);
|
||||
const cardC = createCard('/models/c');
|
||||
|
||||
bulk.toggleCardSelection(cardC, true);
|
||||
|
||||
expect([...state.selectedModels]).toEqual(['/models/c']);
|
||||
expect(cardC.classList.contains('selected')).toBe(true);
|
||||
// The fallback click becomes the new anchor
|
||||
expect(bulk.bulkAnchorFilepath).toBe('/models/c');
|
||||
});
|
||||
|
||||
it('shift+click falls back to a plain toggle when the anchor is no longer listed', () => {
|
||||
const items = attachFakeScroller(['/models/a', '/models/b', '/models/c', '/models/d']);
|
||||
const cards = ['/models/a', '/models/b', '/models/c', '/models/d'].map(createCard);
|
||||
|
||||
bulk.toggleCardSelection(cards[0]);
|
||||
|
||||
// Simulate a filter change removing the anchor from the loaded items
|
||||
items.splice(0, 1);
|
||||
|
||||
bulk.toggleCardSelection(cards[3], true);
|
||||
|
||||
expect([...state.selectedModels].sort()).toEqual(['/models/a', '/models/d']);
|
||||
expect(bulk.bulkAnchorFilepath).toBe('/models/d');
|
||||
});
|
||||
|
||||
it('range selection populates metadata cache for items without rendered cards', () => {
|
||||
attachFakeScroller(['/models/a', '/models/b', '/models/c', '/models/d']);
|
||||
|
||||
// Only the endpoints exist in the DOM (b, c are virtualized away)
|
||||
const cardA = createCard('/models/a');
|
||||
const cardD = createCard('/models/d');
|
||||
|
||||
bulk.toggleCardSelection(cardA);
|
||||
bulk.toggleCardSelection(cardD, true);
|
||||
|
||||
expect([...state.selectedModels].sort()).toEqual(['/models/a', '/models/b', '/models/c', '/models/d']);
|
||||
const cacheB = state.loraMetadataCache.get('/models/b');
|
||||
expect(cacheB.fileName).toBe('b');
|
||||
});
|
||||
|
||||
it('exiting bulk mode clears the anchor along with the selection', () => {
|
||||
const cardA = createCard('/models/a');
|
||||
bulk.toggleCardSelection(cardA);
|
||||
expect(bulk.bulkAnchorFilepath).toBe('/models/a');
|
||||
|
||||
bulk.toggleBulkMode();
|
||||
|
||||
expect(bulk.bulkAnchorFilepath).toBeNull();
|
||||
expect(state.selectedModels.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -75,7 +75,10 @@ describe('MasonryScroller', () => {
|
||||
while (liveScrollers.length > 0) {
|
||||
liveScrollers.pop().dispose();
|
||||
}
|
||||
getCurrentPageState().duplicatesMode = false;
|
||||
const pageState = getCurrentPageState();
|
||||
pageState.duplicatesMode = false;
|
||||
pageState.bulkMode = false;
|
||||
pageState.selectedModels.clear();
|
||||
});
|
||||
|
||||
function track(setup) {
|
||||
@@ -235,6 +238,94 @@ describe('MasonryScroller', () => {
|
||||
expect(rendered[0].style.minWidth).toBe('0');
|
||||
});
|
||||
|
||||
// Root-is-.model-card stub with data-filepath; the .card-preview child is
|
||||
// required by updateSingleItem's update indicator.
|
||||
function createRecipeStyleCreateItemFn() {
|
||||
return (item) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'model-card';
|
||||
el.dataset.filepath = item.file_path;
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'card-preview';
|
||||
el.appendChild(preview);
|
||||
return el;
|
||||
};
|
||||
}
|
||||
|
||||
it('restores .selected on recreated cards after scroll recycling in bulk mode', () => {
|
||||
const items = makeItems(Array.from({ length: 6 }, () => ({ width: 100, height: 100 })));
|
||||
const pageState = getCurrentPageState();
|
||||
const { scroller, grid, wrapper } = track(createScroller({
|
||||
items,
|
||||
viewportHeight: 300,
|
||||
createItemFn: createRecipeStyleCreateItemFn(),
|
||||
}));
|
||||
|
||||
scroller.refreshWithData(items, items.length, false);
|
||||
wrapper.scrollTop = 0;
|
||||
scroller.renderItems();
|
||||
expect(grid.querySelectorAll('.virtual-scroll-item').length).toBeGreaterThan(0);
|
||||
|
||||
pageState.bulkMode = true;
|
||||
pageState.selectedModels.add('/recipes/item-0.png');
|
||||
pageState.selectedModels.add('/recipes/item-1.png');
|
||||
|
||||
wrapper.scrollTop = 100000;
|
||||
scroller.renderItems();
|
||||
expect(grid.querySelectorAll('.virtual-scroll-item').length).toBe(0);
|
||||
|
||||
wrapper.scrollTop = 0;
|
||||
scroller.renderItems();
|
||||
|
||||
const selectedPaths = [...grid.querySelectorAll('.model-card.selected')]
|
||||
.map((card) => card.dataset.filepath)
|
||||
.sort();
|
||||
expect(selectedPaths).toEqual(['/recipes/item-0.png', '/recipes/item-1.png']);
|
||||
});
|
||||
|
||||
it('does not mark recreated cards as selected when bulk mode is off', () => {
|
||||
const items = makeItems([{ width: 100, height: 100 }]);
|
||||
const pageState = getCurrentPageState();
|
||||
const { scroller, grid, wrapper } = track(createScroller({
|
||||
items,
|
||||
viewportHeight: 3000,
|
||||
createItemFn: createRecipeStyleCreateItemFn(),
|
||||
}));
|
||||
|
||||
pageState.selectedModels.add('/recipes/item-0.png');
|
||||
|
||||
scroller.refreshWithData(items, items.length, false);
|
||||
wrapper.scrollTop = 0;
|
||||
scroller.renderItems();
|
||||
|
||||
expect(grid.querySelectorAll('.model-card.selected').length).toBe(0);
|
||||
});
|
||||
|
||||
it('updateSingleItem keeps the selected class on the rebuilt card', () => {
|
||||
const items = makeItems([{ width: 100, height: 100 }, { width: 100, height: 100 }]);
|
||||
const pageState = getCurrentPageState();
|
||||
const { scroller, grid, wrapper } = track(createScroller({
|
||||
items,
|
||||
viewportHeight: 3000,
|
||||
createItemFn: createRecipeStyleCreateItemFn(),
|
||||
}));
|
||||
|
||||
pageState.bulkMode = true;
|
||||
pageState.selectedModels.add('/recipes/item-1.png');
|
||||
|
||||
scroller.refreshWithData(items, items.length, false);
|
||||
wrapper.scrollTop = 0;
|
||||
scroller.renderItems();
|
||||
|
||||
const result = scroller.updateSingleItem('/recipes/item-1.png', { title: 'Updated title' });
|
||||
expect(result).toBe(true);
|
||||
|
||||
const updatedCard = grid.querySelector('.virtual-scroll-item.updated');
|
||||
expect(updatedCard).not.toBeNull();
|
||||
expect(updatedCard.classList.contains('selected')).toBe(true);
|
||||
expect(updatedCard.dataset.filepath).toBe('/recipes/item-1.png');
|
||||
});
|
||||
|
||||
it('triggers loadMoreItems when scrolled to the bottom', async () => {
|
||||
const firstPage = makeItems([
|
||||
{ width: 100, height: 200 },
|
||||
@@ -637,3 +728,68 @@ describe('MasonryScroller', () => {
|
||||
expect(scroller.findIndexByFilePath('')).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VirtualScroller selection restore', () => {
|
||||
beforeEach(() => {
|
||||
setCurrentPageType('recipes');
|
||||
getCurrentPageState().duplicatesMode = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
const pageState = getCurrentPageState();
|
||||
pageState.duplicatesMode = false;
|
||||
pageState.bulkMode = false;
|
||||
pageState.selectedModels.clear();
|
||||
});
|
||||
|
||||
function createVirtualScroller({ items, 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 scroller = new VirtualScroller({
|
||||
gridElement: grid,
|
||||
containerElement: wrapper,
|
||||
scrollContainer: wrapper,
|
||||
createItemFn: (item) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'model-card';
|
||||
el.dataset.filepath = item.file_path;
|
||||
return el;
|
||||
},
|
||||
fetchItemsFn: vi.fn(async () => ({ items, totalItems: items.length, hasMore: false })),
|
||||
});
|
||||
|
||||
return { scroller, grid, wrapper };
|
||||
}
|
||||
|
||||
it('restores .selected on cards recreated after scrolling away and back', () => {
|
||||
const items = makeItems(Array.from({ length: 6 }, () => ({ width: 100, height: 100 })));
|
||||
const pageState = getCurrentPageState();
|
||||
const { scroller, grid, wrapper } = createVirtualScroller({ items, viewportHeight: 600 });
|
||||
|
||||
scroller.refreshWithData(items, items.length, false);
|
||||
wrapper.scrollTop = 0;
|
||||
scroller.renderItems();
|
||||
|
||||
pageState.bulkMode = true;
|
||||
pageState.selectedModels.add('/recipes/item-0.png');
|
||||
pageState.selectedModels.add('/recipes/item-2.png');
|
||||
|
||||
wrapper.scrollTop = 1000000;
|
||||
scroller.renderItems();
|
||||
expect(grid.querySelectorAll('.virtual-scroll-item').length).toBe(0);
|
||||
|
||||
wrapper.scrollTop = 0;
|
||||
scroller.renderItems();
|
||||
|
||||
const selectedPaths = [...grid.querySelectorAll('.model-card.selected')]
|
||||
.map((card) => card.dataset.filepath)
|
||||
.sort();
|
||||
expect(selectedPaths).toEqual(['/recipes/item-0.png', '/recipes/item-2.png']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user