feat(frontend): add Other Models page with subtype filter and badges

This commit is contained in:
Will Miao
2026-09-12 11:25:51 +08:00
parent 27da7b3ca3
commit fa7ce725c1
45 changed files with 988 additions and 30 deletions
@@ -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');
});
});