mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
feat(frontend): add Other Models page with subtype filter and badges
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
// Import order matters: api modules are circularly dependent
|
||||
// (modelApiFactory -> loraApi -> baseModelApi -> modelApiFactory/state).
|
||||
// Loading the factory first lets baseModelApi fully evaluate before the
|
||||
// client subclasses extend it.
|
||||
import { createModelApiClient, getModelApiClient } from '../../../static/js/api/modelApiFactory.js';
|
||||
import {
|
||||
MODEL_TYPES,
|
||||
MODEL_CONFIG,
|
||||
getApiEndpoints,
|
||||
getCompleteApiConfig,
|
||||
isValidModelType,
|
||||
} from '../../../static/js/api/apiConfig.js';
|
||||
import { OtherApiClient } from '../../../static/js/api/otherApi.js';
|
||||
|
||||
describe('apiConfig - other model type', () => {
|
||||
it('exposes OTHER model type', () => {
|
||||
expect(MODEL_TYPES.OTHER).toBe('other');
|
||||
expect(isValidModelType('other')).toBe(true);
|
||||
});
|
||||
|
||||
it('has a complete MODEL_CONFIG entry', () => {
|
||||
const config = MODEL_CONFIG[MODEL_TYPES.OTHER];
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config.singularName).toBe('other');
|
||||
expect(config.supportsLetterFilter).toBe(false);
|
||||
expect(config.supportsBulkOperations).toBe(true);
|
||||
expect(config.supportsMove).toBe(true);
|
||||
expect(config.templateName).toBe('other.html');
|
||||
});
|
||||
|
||||
it('generates /api/lm/other/* endpoints', () => {
|
||||
const endpoints = getApiEndpoints('other');
|
||||
|
||||
expect(endpoints.list).toBe('/api/lm/other/list');
|
||||
expect(endpoints.delete).toBe('/api/lm/other/delete');
|
||||
expect(endpoints.exclude).toBe('/api/lm/other/exclude');
|
||||
expect(endpoints.unexclude).toBe('/api/lm/other/unexclude');
|
||||
expect(endpoints.rename).toBe('/api/lm/other/rename');
|
||||
expect(endpoints.save).toBe('/api/lm/other/save-metadata');
|
||||
expect(endpoints.bulkDelete).toBe('/api/lm/other/bulk-delete');
|
||||
expect(endpoints.moveModel).toBe('/api/lm/other/move_model');
|
||||
expect(endpoints.moveBulk).toBe('/api/lm/other/move_models_bulk');
|
||||
expect(endpoints.fetchCivitai).toBe('/api/lm/other/fetch-civitai');
|
||||
expect(endpoints.fetchAllCivitai).toBe('/api/lm/other/fetch-all-civitai');
|
||||
expect(endpoints.scan).toBe('/api/lm/other/scan');
|
||||
expect(endpoints.topTags).toBe('/api/lm/other/top-tags');
|
||||
expect(endpoints.baseModels).toBe('/api/lm/other/base-models');
|
||||
expect(endpoints.roots).toBe('/api/lm/other/roots');
|
||||
expect(endpoints.folders).toBe('/api/lm/other/folders');
|
||||
expect(endpoints.duplicates).toBe('/api/lm/other/find-duplicates');
|
||||
expect(endpoints.replacePreview).toBe('/api/lm/other/replace-preview');
|
||||
});
|
||||
|
||||
it('merges other-specific endpoints into the complete config', () => {
|
||||
const config = getCompleteApiConfig('other');
|
||||
|
||||
expect(config.modelType).toBe('other');
|
||||
expect(config.config).toBe(MODEL_CONFIG.other);
|
||||
expect(config.endpoints.specific.metadata).toBe('/api/lm/other/metadata');
|
||||
});
|
||||
});
|
||||
|
||||
describe('modelApiFactory - other model type', () => {
|
||||
it('creates an OtherApiClient for the other model type', () => {
|
||||
const client = createModelApiClient(MODEL_TYPES.OTHER);
|
||||
|
||||
expect(client).toBeInstanceOf(OtherApiClient);
|
||||
expect(client.modelType).toBe('other');
|
||||
expect(client.apiConfig.endpoints.list).toBe('/api/lm/other/list');
|
||||
});
|
||||
|
||||
it('returns a cached singleton from getModelApiClient', () => {
|
||||
const first = getModelApiClient(MODEL_TYPES.OTHER);
|
||||
const second = getModelApiClient(MODEL_TYPES.OTHER);
|
||||
|
||||
expect(first).toBeInstanceOf(OtherApiClient);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it('still rejects unsupported model types', () => {
|
||||
expect(() => createModelApiClient('bogus')).toThrow('Unsupported model type: bogus');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const {
|
||||
MODEL_CARD_MODULE,
|
||||
STATE_MODULE,
|
||||
UI_HELPERS_MODULE,
|
||||
I18N_MODULE,
|
||||
API_CONFIG_MODULE,
|
||||
API_FACTORY_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
MODEL_CARD_MODULE: new URL('../../../static/js/components/shared/ModelCard.js', import.meta.url).pathname,
|
||||
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||
API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname,
|
||||
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
vi.mock(STATE_MODULE, () => ({
|
||||
state: {
|
||||
settings: {
|
||||
blur_mature_content: false,
|
||||
model_name_display: 'model_name',
|
||||
},
|
||||
global: {
|
||||
settings: {
|
||||
model_name_display: 'model_name',
|
||||
group_by_model: false,
|
||||
display_density: 'default',
|
||||
model_card_footer_action: 'example_images',
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
other: {
|
||||
previewVersions: new Map(),
|
||||
sortBy: 'name',
|
||||
},
|
||||
},
|
||||
bulkMode: false,
|
||||
selectedModels: new Set(),
|
||||
selectedLoras: new Set(),
|
||||
},
|
||||
getCurrentPageState: vi.fn(() => ({
|
||||
sortBy: 'name',
|
||||
previewVersions: new Map(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||
showToast: vi.fn(),
|
||||
openCivitai: vi.fn(),
|
||||
openHuggingFace: vi.fn(),
|
||||
copyToClipboard: vi.fn(),
|
||||
copyLoraSyntax: vi.fn(),
|
||||
sendLoraToWorkflow: vi.fn(),
|
||||
sendEmbeddingToWorkflow: vi.fn(),
|
||||
openExampleImagesFolder: vi.fn(),
|
||||
buildLoraSyntax: vi.fn(),
|
||||
sendModelPathToWorkflow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(I18N_MODULE, () => ({
|
||||
translate: vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key)),
|
||||
}));
|
||||
|
||||
vi.mock(API_CONFIG_MODULE, () => ({
|
||||
MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings', OTHER: 'other' },
|
||||
}));
|
||||
|
||||
vi.mock(API_FACTORY_MODULE, () => ({
|
||||
getModelApiClient: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
function createOtherModel(overrides = {}) {
|
||||
return {
|
||||
sha256: 'abc123',
|
||||
file_path: '/models/vae/test_vae.safetensors',
|
||||
model_name: 'Test VAE',
|
||||
file_name: 'test_vae',
|
||||
folder: 'vae',
|
||||
modified: 1234567890,
|
||||
file_size: 1024,
|
||||
notes: '',
|
||||
base_model: '',
|
||||
favorite: false,
|
||||
exclude: false,
|
||||
hf_url: '',
|
||||
update_available: false,
|
||||
skip_metadata_refresh: false,
|
||||
preview_url: '',
|
||||
preview_nsfw_level: 0,
|
||||
tags: [],
|
||||
civitai: {},
|
||||
sub_type: 'vae',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ModelCard sub-type badges for other model types', () => {
|
||||
let createModelCard;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ createModelCard } = await import(MODEL_CARD_MODULE));
|
||||
});
|
||||
|
||||
it.each([
|
||||
['vae', 'VAE', 'VAE'],
|
||||
['upscaler', 'UPS', 'Upscaler'],
|
||||
['text_encoder', 'TE', 'Text Encoder'],
|
||||
['clip_vision', 'CV', 'CLIP Vision'],
|
||||
['controlnet', 'CN', 'ControlNet'],
|
||||
])('renders the %s badge abbreviation and tooltip', (subType, abbreviation, displayName) => {
|
||||
const card = createModelCard(createOtherModel({ sub_type: subType }), 'other');
|
||||
|
||||
const badge = card.querySelector('.model-sub-type');
|
||||
expect(badge).not.toBeNull();
|
||||
expect(badge.textContent).toBe(abbreviation);
|
||||
|
||||
const label = card.querySelector('.base-model-label');
|
||||
expect(label.getAttribute('title')).toContain(displayName);
|
||||
});
|
||||
|
||||
it('stores sub_type on the card dataset', () => {
|
||||
const card = createModelCard(createOtherModel({ sub_type: 'text_encoder' }), 'other');
|
||||
|
||||
expect(card.dataset.sub_type).toBe('text_encoder');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
getModelApiClientMock,
|
||||
resetAndReloadMock,
|
||||
showToastMock,
|
||||
sidebarManagerMock,
|
||||
moveManagerMock,
|
||||
showDeleteModalMock,
|
||||
showExcludeModalMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getModelApiClientMock: vi.fn(),
|
||||
resetAndReloadMock: vi.fn(async () => {}),
|
||||
showToastMock: vi.fn(),
|
||||
sidebarManagerMock: {
|
||||
setHostPageControls: vi.fn(),
|
||||
initialize: vi.fn(async function () {
|
||||
sidebarManagerMock.isInitialized = true;
|
||||
}),
|
||||
refresh: vi.fn(async () => {}),
|
||||
cleanup: vi.fn(),
|
||||
isInitialized: false,
|
||||
},
|
||||
moveManagerMock: {
|
||||
showMoveModal: vi.fn(),
|
||||
},
|
||||
showDeleteModalMock: vi.fn(),
|
||||
showExcludeModalMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||
getModelApiClient: getModelApiClientMock,
|
||||
resetAndReload: resetAndReloadMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: showToastMock,
|
||||
openCivitaiByMetadata: vi.fn(),
|
||||
isTypingContext: () => false,
|
||||
getNSFWLevelName: vi.fn(() => 'Unknown'),
|
||||
openExampleImagesFolder: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/managers/DownloadManager.js', () => ({
|
||||
downloadManager: { showDownloadModal: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/SidebarManager.js', () => ({
|
||||
sidebarManager: sidebarManagerMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/managers/MoveManager.js', () => ({
|
||||
moveManager: moveManagerMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/modalUtils.js', () => ({
|
||||
showDeleteModal: showDeleteModalMock,
|
||||
showExcludeModal: showExcludeModalMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/alphabet/index.js', () => ({
|
||||
createAlphabetBar: vi.fn(() => ({ destroy: vi.fn() })),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/updateCheckHelpers.js', () => ({
|
||||
performModelUpdateCheck: vi.fn(async () => ({ status: 'success', displayName: 'Model', records: [] })),
|
||||
}));
|
||||
|
||||
import { createPageControls } from '../../../static/js/components/controls/index.js';
|
||||
import { OtherControls } from '../../../static/js/components/controls/OtherControls.js';
|
||||
import { createPageContextMenu } from '../../../static/js/components/ContextMenu/index.js';
|
||||
import { OtherContextMenu } from '../../../static/js/components/ContextMenu/OtherContextMenu.js';
|
||||
|
||||
describe('createPageControls', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
document.body.innerHTML = '';
|
||||
document.body.dataset.page = 'other';
|
||||
sidebarManagerMock.isInitialized = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.pageControls;
|
||||
delete window.bulkManager;
|
||||
});
|
||||
|
||||
it('creates OtherControls for the other page type', () => {
|
||||
const controls = createPageControls('other');
|
||||
|
||||
expect(controls).toBeInstanceOf(OtherControls);
|
||||
expect(controls.pageType).toBe('other');
|
||||
// OtherControls registers its API with the base class
|
||||
expect(typeof controls.api.loadMoreModels).toBe('function');
|
||||
expect(typeof controls.api.refreshModels).toBe('function');
|
||||
expect(typeof controls.api.fetchFromCivitai).toBe('function');
|
||||
expect(typeof controls.api.toggleBulkMode).toBe('function');
|
||||
});
|
||||
|
||||
it('returns null for an unknown page type', () => {
|
||||
expect(createPageControls('not-a-page')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPageContextMenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = '<div id="otherContextMenu" class="context-menu" style="display: none;"></div>';
|
||||
});
|
||||
|
||||
function createMenuWithCard() {
|
||||
const menu = createPageContextMenu('other');
|
||||
const card = document.createElement('div');
|
||||
card.className = 'model-card';
|
||||
card.dataset.filepath = '/models/vae/test.safetensors';
|
||||
document.body.appendChild(card);
|
||||
menu.currentCard = card;
|
||||
return { menu, card };
|
||||
}
|
||||
|
||||
it('creates OtherContextMenu for the other page type', () => {
|
||||
const menu = createPageContextMenu('other');
|
||||
|
||||
expect(menu).toBeInstanceOf(OtherContextMenu);
|
||||
expect(menu.modelType).toBe('other');
|
||||
expect(menu.menu).toBe(document.getElementById('otherContextMenu'));
|
||||
});
|
||||
|
||||
it('returns null for an unknown page type', () => {
|
||||
expect(createPageContextMenu('not-a-page')).toBeNull();
|
||||
});
|
||||
|
||||
it('delegates refresh-metadata to the model API client', () => {
|
||||
const refreshSingleModelMetadata = vi.fn();
|
||||
getModelApiClientMock.mockReturnValue({ refreshSingleModelMetadata });
|
||||
const { menu } = createMenuWithCard();
|
||||
|
||||
menu.handleMenuAction('refresh-metadata');
|
||||
|
||||
expect(refreshSingleModelMetadata).toHaveBeenCalledWith('/models/vae/test.safetensors');
|
||||
});
|
||||
|
||||
it('opens the move modal for the move action', () => {
|
||||
const { menu } = createMenuWithCard();
|
||||
|
||||
menu.handleMenuAction('move');
|
||||
|
||||
expect(moveManagerMock.showMoveModal).toHaveBeenCalledWith('/models/vae/test.safetensors');
|
||||
});
|
||||
|
||||
it('shows the exclude modal for the exclude action', () => {
|
||||
const { menu } = createMenuWithCard();
|
||||
|
||||
menu.handleMenuAction('exclude');
|
||||
|
||||
expect(showExcludeModalMock).toHaveBeenCalledWith('/models/vae/test.safetensors');
|
||||
});
|
||||
});
|
||||
@@ -120,7 +120,7 @@ import { initializeEventManagement } from '../../../static/js/utils/eventManagem
|
||||
import { initializeInfiniteScroll } from '../../../static/js/utils/infiniteScroll.js';
|
||||
import { createPageContextMenu, createGlobalContextMenu } from '../../../static/js/components/ContextMenu/index.js';
|
||||
|
||||
const SUPPORTED_PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings'];
|
||||
const SUPPORTED_PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings', 'other'];
|
||||
|
||||
describe('AppCore page orchestration', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { renderOtherPage } from '../utils/pageFixtures.js';
|
||||
|
||||
const initializeAppMock = vi.fn();
|
||||
const initializePageFeaturesMock = vi.fn();
|
||||
const createPageControlsMock = vi.fn();
|
||||
const confirmDeleteMock = vi.fn();
|
||||
const closeDeleteModalMock = vi.fn();
|
||||
const confirmExcludeMock = vi.fn();
|
||||
const closeExcludeModalMock = vi.fn();
|
||||
const duplicatesManagerMock = vi.fn();
|
||||
const initActiveFiltersSyncMock = vi.fn();
|
||||
|
||||
vi.mock('../../../static/js/core.js', () => ({
|
||||
appCore: {
|
||||
initialize: initializeAppMock,
|
||||
initializePageFeatures: initializePageFeaturesMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/controls/index.js', () => ({
|
||||
createPageControls: createPageControlsMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/modalUtils.js', () => ({
|
||||
confirmDelete: confirmDeleteMock,
|
||||
closeDeleteModal: closeDeleteModalMock,
|
||||
confirmExclude: confirmExcludeMock,
|
||||
closeExcludeModal: closeExcludeModalMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||
MODEL_TYPES: {
|
||||
OTHER: 'other',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({
|
||||
ModelDuplicatesManager: duplicatesManagerMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/activeFiltersSync.js', () => ({
|
||||
initActiveFiltersSync: initActiveFiltersSyncMock,
|
||||
}));
|
||||
|
||||
describe('OtherPageManager', () => {
|
||||
let OtherPageManager;
|
||||
let initializeOtherPage;
|
||||
let duplicatesManagerInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
|
||||
duplicatesManagerInstance = {
|
||||
checkDuplicatesCount: vi.fn(),
|
||||
};
|
||||
|
||||
duplicatesManagerMock.mockReturnValue(duplicatesManagerInstance);
|
||||
createPageControlsMock.mockReturnValue({ destroy: vi.fn() });
|
||||
initializeAppMock.mockResolvedValue(undefined);
|
||||
|
||||
renderOtherPage();
|
||||
|
||||
({ OtherPageManager, initializeOtherPage } = await import('../../../static/js/other.js'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.confirmDelete;
|
||||
delete window.closeDeleteModal;
|
||||
delete window.confirmExclude;
|
||||
delete window.closeExcludeModal;
|
||||
delete window.modelDuplicatesManager;
|
||||
});
|
||||
|
||||
it('wires page controls and exposes modal helpers during construction', () => {
|
||||
const manager = new OtherPageManager();
|
||||
|
||||
expect(createPageControlsMock).toHaveBeenCalledWith('other');
|
||||
expect(duplicatesManagerMock).toHaveBeenCalledWith(manager, 'other');
|
||||
|
||||
expect(window.confirmDelete).toBe(confirmDeleteMock);
|
||||
expect(window.closeDeleteModal).toBe(closeDeleteModalMock);
|
||||
expect(window.confirmExclude).toBe(confirmExcludeMock);
|
||||
expect(window.closeExcludeModal).toBe(closeExcludeModalMock);
|
||||
expect(window.modelDuplicatesManager).toBe(duplicatesManagerInstance);
|
||||
});
|
||||
|
||||
it('initializes shared page features and syncs active filters', async () => {
|
||||
const manager = new OtherPageManager();
|
||||
|
||||
await manager.initialize();
|
||||
|
||||
expect(initializePageFeaturesMock).toHaveBeenCalledTimes(1);
|
||||
expect(initActiveFiltersSyncMock).toHaveBeenCalledWith('other');
|
||||
});
|
||||
|
||||
it('boots the other models page through the initializer', async () => {
|
||||
const manager = await initializeOtherPage();
|
||||
|
||||
expect(initializeAppMock).toHaveBeenCalledTimes(1);
|
||||
expect(manager).toBeInstanceOf(OtherPageManager);
|
||||
expect(window.modelDuplicatesManager).toBe(duplicatesManagerInstance);
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,18 @@ export function renderEmbeddingsPage() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the Other Models page template with expected dataset attributes.
|
||||
* @returns {Element}
|
||||
*/
|
||||
export function renderOtherPage() {
|
||||
return renderTemplate('other.html', {
|
||||
dataset: {
|
||||
page: 'other',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the Recipes page template with expected dataset attributes.
|
||||
* @returns {Element}
|
||||
|
||||
Reference in New Issue
Block a user