mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-28 08:21:27 -03:00
feat(models): add shared searchable base model picker and overhaul bulk base model modal
- Extract a shared BaseModelPicker (search, keyboard navigation, filename-based suggestions, dynamic API models such as MiniMax H3 under 'Other (API)') used by both the single-model metadata modal and the bulk base model modal - Rework the bulk base model modal into a dedicated inline-list layout: fixed modal size, sticky-free footer with app-standard modal-actions/primary-btn/cancel-btn buttons, and an inline option list that scrolls itself instead of an overlay dropdown covering the footer - Selecting an option in change mode now filters the list to the selection instead of resetting and scroll-jumping to it - Restore opaque sticky section headers in the bulk modal so scrolled items no longer bleed through
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
translate: vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key)),
|
||||
}));
|
||||
|
||||
import {
|
||||
createBaseModelPicker,
|
||||
inferBaseModelsFromFilename,
|
||||
inferBaseModelsFromFilepaths,
|
||||
} from '../../../static/js/components/shared/BaseModelPicker.js';
|
||||
import {
|
||||
setDynamicBaseModels,
|
||||
clearDynamicBaseModels,
|
||||
BASE_MODELS_UPDATED_EVENT,
|
||||
} from '../../../static/js/utils/constants.js';
|
||||
|
||||
// jsdom does not implement scrollIntoView
|
||||
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || vi.fn();
|
||||
|
||||
const flushDebounce = () => new Promise((resolve) => setTimeout(resolve, 70));
|
||||
|
||||
function mountPicker(options = {}) {
|
||||
const picker = createBaseModelPicker(options);
|
||||
document.body.appendChild(picker.element);
|
||||
return picker;
|
||||
}
|
||||
|
||||
function getInput(picker) {
|
||||
return picker.element.querySelector('.base-model-search-input');
|
||||
}
|
||||
|
||||
function getDropdown(picker) {
|
||||
return picker.element.querySelector('.base-model-dropdown');
|
||||
}
|
||||
|
||||
function getItemValues(picker) {
|
||||
return Array.from(picker.element.querySelectorAll('.base-model-dropdown-item'))
|
||||
.map((el) => el.dataset.value);
|
||||
}
|
||||
|
||||
describe('inferBaseModelsFromFilepaths', () => {
|
||||
it('returns an empty array for empty or invalid input', () => {
|
||||
expect(inferBaseModelsFromFilepaths([])).toEqual([]);
|
||||
expect(inferBaseModelsFromFilepaths(null)).toEqual([]);
|
||||
expect(inferBaseModelsFromFilepaths(['/models/zzz_unknown.safetensors'])).toEqual([]);
|
||||
});
|
||||
|
||||
it('deduplicates and sorts by hit count across selected paths', () => {
|
||||
const result = inferBaseModelsFromFilepaths([
|
||||
'/loras/flux1_dev_alpha.safetensors',
|
||||
'/loras/another_flux_model.safetensors',
|
||||
'C:\\models\\sdxl_style.safetensors',
|
||||
]);
|
||||
|
||||
// Flux.1 D matched two paths, so it ranks first; entries are deduplicated
|
||||
expect(result[0]).toBe('Flux.1 D');
|
||||
expect(new Set(result).size).toBe(result.length);
|
||||
expect(result).toContain('SDXL 1.0');
|
||||
});
|
||||
|
||||
it('infers base models from a single filename', () => {
|
||||
expect(inferBaseModelsFromFilename('my_pony_lora.safetensors')).toContain('Pony');
|
||||
expect(inferBaseModelsFromFilename('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBaseModelPicker', () => {
|
||||
beforeEach(() => {
|
||||
clearDynamicBaseModels();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearDynamicBaseModels();
|
||||
});
|
||||
|
||||
it('groups uncategorized dynamic models under "Other (API)"', () => {
|
||||
setDynamicBaseModels(['MiniMax H3'], new Date().toISOString());
|
||||
const picker = mountPicker();
|
||||
|
||||
const headers = Array.from(picker.element.querySelectorAll('.base-model-dropdown-header'));
|
||||
const otherHeader = headers.find((el) => el.textContent === 'Other (API)');
|
||||
expect(otherHeader).toBeTruthy();
|
||||
|
||||
const section = otherHeader.closest('.base-model-dropdown-section');
|
||||
const values = Array.from(section.querySelectorAll('.base-model-dropdown-item'))
|
||||
.map((el) => el.dataset.value);
|
||||
expect(values).toContain('MiniMax H3');
|
||||
|
||||
picker.destroy();
|
||||
});
|
||||
|
||||
it('filters options case-insensitively after the debounce', async () => {
|
||||
setDynamicBaseModels(['MiniMax H3'], new Date().toISOString());
|
||||
const picker = mountPicker();
|
||||
|
||||
const input = getInput(picker);
|
||||
input.value = 'MINIMAX';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await flushDebounce();
|
||||
|
||||
expect(getItemValues(picker)).toEqual(['MiniMax H3']);
|
||||
|
||||
picker.destroy();
|
||||
});
|
||||
|
||||
it('shows the empty state when nothing matches', async () => {
|
||||
const picker = mountPicker();
|
||||
|
||||
const input = getInput(picker);
|
||||
input.value = 'no-such-model-xyz';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await flushDebounce();
|
||||
|
||||
expect(getItemValues(picker)).toEqual([]);
|
||||
expect(getDropdown(picker).querySelector('.base-model-dropdown-empty')).toBeTruthy();
|
||||
|
||||
picker.destroy();
|
||||
});
|
||||
|
||||
it('commits immediately on item click in commit mode', () => {
|
||||
const onCommit = vi.fn();
|
||||
const picker = mountPicker({ onCommit });
|
||||
|
||||
const item = Array.from(picker.element.querySelectorAll('.base-model-dropdown-item'))
|
||||
.find((el) => el.dataset.value === 'SDXL 1.0');
|
||||
item.click();
|
||||
|
||||
expect(onCommit).toHaveBeenCalledWith('SDXL 1.0');
|
||||
|
||||
picker.destroy();
|
||||
});
|
||||
|
||||
it('supports keyboard navigation and Enter to commit the active item', () => {
|
||||
const onCommit = vi.fn();
|
||||
const picker = mountPicker({ onCommit });
|
||||
const input = getInput(picker);
|
||||
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
|
||||
|
||||
const active = picker.element.querySelector('.base-model-dropdown-item.active');
|
||||
expect(active).toBeTruthy();
|
||||
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
expect(onCommit).toHaveBeenCalledWith(active.dataset.value);
|
||||
|
||||
picker.destroy();
|
||||
});
|
||||
|
||||
it('commits a custom typed value on Enter', () => {
|
||||
const onCommit = vi.fn();
|
||||
const picker = mountPicker({ onCommit });
|
||||
const input = getInput(picker);
|
||||
|
||||
input.value = 'My Custom Model';
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
|
||||
expect(onCommit).toHaveBeenCalledWith('My Custom Model');
|
||||
expect(picker.getValue()).toBe('My Custom Model');
|
||||
|
||||
picker.destroy();
|
||||
});
|
||||
|
||||
it('keeps typed text search-only on Enter when allowCustomValue is false', () => {
|
||||
const onCommit = vi.fn();
|
||||
const picker = mountPicker({ onCommit, allowCustomValue: false });
|
||||
const input = getInput(picker);
|
||||
|
||||
input.value = 'My Custom Model';
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
expect(picker.getValue()).toBe('');
|
||||
|
||||
picker.destroy();
|
||||
});
|
||||
|
||||
it('commits the typed custom value on outside click', async () => {
|
||||
const onCommit = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
const picker = mountPicker({ onCommit, onDismiss, initialValue: 'SD 1.5' });
|
||||
const input = getInput(picker);
|
||||
|
||||
input.value = 'My Custom Model';
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
document.body.click();
|
||||
|
||||
expect(onCommit).toHaveBeenCalledWith('My Custom Model');
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
expect(picker.getValue()).toBe('My Custom Model');
|
||||
|
||||
picker.destroy();
|
||||
});
|
||||
|
||||
it('dismisses without committing typed search text on outside click when allowCustomValue is false', async () => {
|
||||
const onCommit = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
const picker = mountPicker({ onCommit, onDismiss, initialValue: 'SD 1.5', allowCustomValue: false });
|
||||
const input = getInput(picker);
|
||||
|
||||
input.value = 'My Custom Model';
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
document.body.click();
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
expect(picker.getValue()).toBe('SD 1.5');
|
||||
|
||||
picker.destroy();
|
||||
});
|
||||
|
||||
it('dismisses without committing on Escape', () => {
|
||||
const onCommit = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
const picker = mountPicker({ onCommit, onDismiss, initialValue: 'SD 1.5' });
|
||||
const input = getInput(picker);
|
||||
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
|
||||
picker.destroy();
|
||||
});
|
||||
|
||||
it('refreshes options when dynamic models arrive late and keeps the search text', async () => {
|
||||
const picker = mountPicker();
|
||||
const input = getInput(picker);
|
||||
|
||||
input.value = 'minimax';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await flushDebounce();
|
||||
expect(getItemValues(picker)).toEqual([]);
|
||||
|
||||
// Dynamic models arrive after the picker is already open
|
||||
setDynamicBaseModels(['MiniMax H3'], new Date().toISOString());
|
||||
window.dispatchEvent(new CustomEvent(BASE_MODELS_UPDATED_EVENT));
|
||||
|
||||
expect(input.value).toBe('minimax');
|
||||
expect(getItemValues(picker)).toEqual(['MiniMax H3']);
|
||||
|
||||
picker.destroy();
|
||||
});
|
||||
|
||||
it('stops reacting to updates after destroy', () => {
|
||||
const picker = mountPicker();
|
||||
picker.destroy();
|
||||
|
||||
setDynamicBaseModels(['MiniMax H3'], new Date().toISOString());
|
||||
window.dispatchEvent(new CustomEvent(BASE_MODELS_UPDATED_EVENT));
|
||||
|
||||
expect(getItemValues(picker)).not.toContain('MiniMax H3');
|
||||
});
|
||||
|
||||
it('renders filename-based suggestions in a Suggested section', () => {
|
||||
const suggestions = inferBaseModelsFromFilename('flux1_dev_model.safetensors');
|
||||
const picker = mountPicker({ suggestions });
|
||||
|
||||
const suggestedHeader = picker.element.querySelector('.base-model-dropdown-header.suggested-header');
|
||||
expect(suggestedHeader).toBeTruthy();
|
||||
|
||||
const section = suggestedHeader.closest('.base-model-dropdown-section');
|
||||
const values = Array.from(section.querySelectorAll('.base-model-dropdown-item'))
|
||||
.map((el) => el.dataset.value);
|
||||
expect(values).toContain('Flux.1 D');
|
||||
|
||||
// Suggested entries are deduplicated out of the categorized sections
|
||||
expect(getItemValues(picker).filter((v) => v === 'Flux.1 D')).toHaveLength(1);
|
||||
|
||||
picker.destroy();
|
||||
});
|
||||
|
||||
it('change mode only notifies via onChange and tracks the value', () => {
|
||||
const onCommit = vi.fn();
|
||||
const onChange = vi.fn();
|
||||
const picker = mountPicker({ mode: 'change', onCommit, onChange });
|
||||
|
||||
const item = Array.from(picker.element.querySelectorAll('.base-model-dropdown-item'))
|
||||
.find((el) => el.dataset.value === 'SDXL 1.0');
|
||||
item.click();
|
||||
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
expect(onChange).toHaveBeenCalledWith('SDXL 1.0');
|
||||
expect(picker.getValue()).toBe('SDXL 1.0');
|
||||
|
||||
// The list collapses to the selected item instead of resetting to the
|
||||
// full list (avoids a scroll jump in the bulk modal's inline list)
|
||||
expect(getItemValues(picker)).toEqual(['SDXL 1.0']);
|
||||
|
||||
picker.destroy();
|
||||
});
|
||||
|
||||
it('treats typed text as the live value in change mode', async () => {
|
||||
const onChange = vi.fn();
|
||||
const picker = mountPicker({ mode: 'change', onChange });
|
||||
const input = getInput(picker);
|
||||
|
||||
input.value = 'Typed Custom';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await flushDebounce();
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('Typed Custom');
|
||||
expect(picker.getValue()).toBe('Typed Custom');
|
||||
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
expect(picker.getValue()).toBe('Typed Custom');
|
||||
// Custom values are not in the option list — the full list stays visible
|
||||
expect(getItemValues(picker).length).toBeGreaterThan(1);
|
||||
|
||||
picker.destroy();
|
||||
});
|
||||
|
||||
it('keeps typed text as search-only in change mode when allowCustomValue is false', async () => {
|
||||
const onChange = vi.fn();
|
||||
const picker = mountPicker({ mode: 'change', onChange, allowCustomValue: false });
|
||||
const input = getInput(picker);
|
||||
|
||||
input.value = 'Typed Custom';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await flushDebounce();
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(picker.getValue()).toBe('');
|
||||
expect(getDropdown(picker).querySelector('.base-model-dropdown-empty')).toBeTruthy();
|
||||
|
||||
picker.destroy();
|
||||
});
|
||||
|
||||
it('setValue updates the input and selected marker', () => {
|
||||
const picker = mountPicker({ mode: 'change' });
|
||||
|
||||
picker.setValue('SD 3.5');
|
||||
|
||||
expect(picker.getValue()).toBe('SD 3.5');
|
||||
const selected = picker.element.querySelector('.base-model-dropdown-item.selected');
|
||||
expect(selected?.dataset.value).toBe('SD 3.5');
|
||||
|
||||
picker.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
import { renderTemplate } from '../utils/domFixtures.js';
|
||||
|
||||
const showToastMock = vi.fn();
|
||||
const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key));
|
||||
|
||||
const loadingManagerStub = {
|
||||
showSimpleLoading: vi.fn(),
|
||||
showCancelButton: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
};
|
||||
|
||||
const stateStub = {
|
||||
currentPageType: 'loras',
|
||||
bulkMode: false,
|
||||
selectedModels: new Set(),
|
||||
loadingManager: loadingManagerStub,
|
||||
virtualScroller: { updateSingleItem: vi.fn() },
|
||||
global: { settings: {} },
|
||||
};
|
||||
|
||||
const saveModelMetadataMock = vi.fn();
|
||||
const getModelApiClientMock = vi.fn(() => ({ saveModelMetadata: saveModelMetadataMock }));
|
||||
const updateRecipeMetadataMock = vi.fn(() => Promise.resolve({ success: true }));
|
||||
const showModalMock = vi.fn();
|
||||
const closeModalMock = vi.fn();
|
||||
|
||||
vi.mock('../../../static/js/state/index.js', () => ({
|
||||
state: stateStub,
|
||||
getCurrentPageState: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: showToastMock,
|
||||
copyToClipboard: vi.fn(),
|
||||
sendLoraToWorkflow: vi.fn(),
|
||||
sendEmbeddingToWorkflow: vi.fn(),
|
||||
buildLoraSyntax: vi.fn(),
|
||||
getNSFWLevelName: vi.fn(() => 'Unknown'),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||
getModelApiClient: getModelApiClientMock,
|
||||
resetAndReload: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||
RecipeSidebarApiClient: class {},
|
||||
updateRecipeMetadata: updateRecipeMetadataMock,
|
||||
extractRecipeId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||
MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings' },
|
||||
MODEL_CONFIG: {},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
|
||||
modalManager: { showModal: showModalMock, closeModal: closeModalMock },
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
|
||||
updateCardsForBulkMode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
translate: translateMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/priorityTagHelpers.js', () => ({
|
||||
getPriorityTagSuggestions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/shared/NsfwLevelSelector.js', () => ({
|
||||
getNsfwLevelSelector: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
setDynamicBaseModels,
|
||||
clearDynamicBaseModels,
|
||||
} from '../../../static/js/utils/constants.js';
|
||||
|
||||
// jsdom does not implement scrollIntoView
|
||||
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || vi.fn();
|
||||
|
||||
function getPickerContainer() {
|
||||
return document.getElementById('bulkBaseModelPicker');
|
||||
}
|
||||
|
||||
function clickDropdownItem(value) {
|
||||
const item = Array.from(document.querySelectorAll('.base-model-dropdown-item'))
|
||||
.find((el) => el.dataset.value === value);
|
||||
expect(item, `dropdown item for "${value}"`).toBeTruthy();
|
||||
item.click();
|
||||
}
|
||||
|
||||
describe('BulkManager bulk base model', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearDynamicBaseModels();
|
||||
stateStub.currentPageType = 'loras';
|
||||
stateStub.bulkMode = false;
|
||||
stateStub.selectedModels.clear();
|
||||
saveModelMetadataMock.mockResolvedValue(undefined);
|
||||
updateRecipeMetadataMock.mockResolvedValue({ success: true });
|
||||
renderTemplate('components/modals/bulk_base_model_modal.html');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearDynamicBaseModels();
|
||||
});
|
||||
|
||||
async function createBulkManager() {
|
||||
const { BulkManager } = await import('../../../static/js/managers/BulkManager.js');
|
||||
return new BulkManager();
|
||||
}
|
||||
|
||||
it('warns when opening the modal without a selection', async () => {
|
||||
const bulk = await createBulkManager();
|
||||
bulk.showBulkBaseModelModal();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.models.noModelsSelected', {}, 'warning');
|
||||
expect(showModalMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('initializes the picker inside the modal container', async () => {
|
||||
const bulk = await createBulkManager();
|
||||
stateStub.selectedModels.add('/models/sdxl_a.safetensors');
|
||||
stateStub.selectedModels.add('/models/sdxl_b.safetensors');
|
||||
|
||||
bulk.showBulkBaseModelModal();
|
||||
|
||||
expect(document.getElementById('bulkBaseModelCount').textContent).toBe('2');
|
||||
const container = getPickerContainer();
|
||||
expect(container.querySelector('.base-model-search-wrapper')).toBeTruthy();
|
||||
expect(bulk.bulkBaseModelPicker).toBeTruthy();
|
||||
|
||||
// Filename-based suggestions from the selected paths
|
||||
const suggestedHeader = container.querySelector('.base-model-dropdown-header.suggested-header');
|
||||
expect(suggestedHeader).toBeTruthy();
|
||||
const suggestedSection = suggestedHeader.closest('.base-model-dropdown-section');
|
||||
expect(suggestedSection.querySelector('.base-model-dropdown-item')?.dataset.value).toBe('SDXL 1.0');
|
||||
|
||||
bulk.cleanupBulkBaseModelModal();
|
||||
});
|
||||
|
||||
it('offers dynamic models such as MiniMax H3 under "Other (API)"', async () => {
|
||||
setDynamicBaseModels(['MiniMax H3'], new Date().toISOString());
|
||||
const bulk = await createBulkManager();
|
||||
stateStub.selectedModels.add('/models/test.safetensors');
|
||||
|
||||
bulk.showBulkBaseModelModal();
|
||||
|
||||
const headers = Array.from(document.querySelectorAll('.base-model-dropdown-header'));
|
||||
const otherHeader = headers.find((el) => el.textContent === 'Other (API)');
|
||||
expect(otherHeader).toBeTruthy();
|
||||
const section = otherHeader.closest('.base-model-dropdown-section');
|
||||
const values = Array.from(section.querySelectorAll('.base-model-dropdown-item'))
|
||||
.map((el) => el.dataset.value);
|
||||
expect(values).toContain('MiniMax H3');
|
||||
|
||||
bulk.cleanupBulkBaseModelModal();
|
||||
});
|
||||
|
||||
it('saves a dynamic base model through the model API on model pages', async () => {
|
||||
setDynamicBaseModels(['MiniMax H3'], new Date().toISOString());
|
||||
const bulk = await createBulkManager();
|
||||
stateStub.currentPageType = 'loras';
|
||||
stateStub.selectedModels.add('/models/a.safetensors');
|
||||
stateStub.selectedModels.add('/models/b.safetensors');
|
||||
|
||||
bulk.showBulkBaseModelModal();
|
||||
clickDropdownItem('MiniMax H3');
|
||||
expect(bulk.bulkBaseModelValue).toBe('MiniMax H3');
|
||||
|
||||
await bulk.saveBulkBaseModel();
|
||||
|
||||
expect(closeModalMock).toHaveBeenCalledWith('bulkBaseModelModal');
|
||||
expect(saveModelMetadataMock).toHaveBeenCalledTimes(2);
|
||||
expect(saveModelMetadataMock).toHaveBeenCalledWith('/models/a.safetensors', { base_model: 'MiniMax H3' });
|
||||
expect(saveModelMetadataMock).toHaveBeenCalledWith('/models/b.safetensors', { base_model: 'MiniMax H3' });
|
||||
expect(updateRecipeMetadataMock).not.toHaveBeenCalled();
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.models.bulkBaseModelUpdateSuccess',
|
||||
{ count: 2 },
|
||||
'success'
|
||||
);
|
||||
});
|
||||
|
||||
it('saves through the recipe API when on the recipes page', async () => {
|
||||
const bulk = await createBulkManager();
|
||||
stateStub.currentPageType = 'recipes';
|
||||
stateStub.selectedModels.add('/recipes/test.webp');
|
||||
|
||||
bulk.showBulkBaseModelModal();
|
||||
clickDropdownItem('SD 1.5');
|
||||
|
||||
await bulk.saveBulkBaseModel();
|
||||
|
||||
expect(updateRecipeMetadataMock).toHaveBeenCalledWith('/recipes/test.webp', { base_model: 'SD 1.5' });
|
||||
expect(updateRecipeMetadataMock).toHaveBeenCalledTimes(1);
|
||||
expect(saveModelMetadataMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns and skips saving when no base model is selected', async () => {
|
||||
const bulk = await createBulkManager();
|
||||
stateStub.selectedModels.add('/models/a.safetensors');
|
||||
|
||||
bulk.showBulkBaseModelModal();
|
||||
await bulk.saveBulkBaseModel();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.models.baseModelNotSelected', {}, 'warning');
|
||||
expect(saveModelMetadataMock).not.toHaveBeenCalled();
|
||||
expect(closeModalMock).not.toHaveBeenCalledWith('bulkBaseModelModal');
|
||||
|
||||
bulk.cleanupBulkBaseModelModal();
|
||||
});
|
||||
|
||||
it('accepts arbitrary typed values in bulk mode, matching the single-model modal', async () => {
|
||||
const bulk = await createBulkManager();
|
||||
stateStub.selectedModels.add('/models/a.safetensors');
|
||||
|
||||
bulk.showBulkBaseModelModal();
|
||||
const input = document.querySelector('#bulkBaseModelPicker .base-model-search-input');
|
||||
input.value = 'Not A Listed Model';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 70));
|
||||
|
||||
expect(bulk.bulkBaseModelValue).toBe('Not A Listed Model');
|
||||
|
||||
await bulk.saveBulkBaseModel();
|
||||
|
||||
expect(saveModelMetadataMock).toHaveBeenCalledWith('/models/a.safetensors', { base_model: 'Not A Listed Model' });
|
||||
expect(closeModalMock).toHaveBeenCalledWith('bulkBaseModelModal');
|
||||
|
||||
bulk.cleanupBulkBaseModelModal();
|
||||
});
|
||||
|
||||
it('uses a dedicated layout without the settings-page control wrapper', () => {
|
||||
const modal = document.getElementById('bulkBaseModelModal');
|
||||
expect(modal).toBeTruthy();
|
||||
expect(modal.querySelector('.setting-control')).toBeNull();
|
||||
expect(modal.querySelector('.metadata-edit-container')).toBeNull();
|
||||
const footer = modal.querySelector('.bulk-base-model-footer');
|
||||
expect(footer).toBeTruthy();
|
||||
// Buttons follow the app-wide modal-actions convention
|
||||
expect(footer.classList.contains('modal-actions')).toBe(true);
|
||||
expect(footer.querySelector('.primary-btn.bulk-save-base-model-btn')).toBeTruthy();
|
||||
expect(footer.querySelector('.cancel-btn')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('focuses the search input when the modal opens', async () => {
|
||||
const bulk = await createBulkManager();
|
||||
stateStub.selectedModels.add('/models/a.safetensors');
|
||||
|
||||
bulk.showBulkBaseModelModal();
|
||||
|
||||
const input = document.querySelector('#bulkBaseModelPicker .base-model-search-input');
|
||||
expect(document.activeElement).toBe(input);
|
||||
|
||||
bulk.cleanupBulkBaseModelModal();
|
||||
});
|
||||
|
||||
it('reports partial failures', async () => {
|
||||
const bulk = await createBulkManager();
|
||||
stateStub.selectedModels.add('/models/ok.safetensors');
|
||||
stateStub.selectedModels.add('/models/fail.safetensors');
|
||||
saveModelMetadataMock
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
bulk.showBulkBaseModelModal();
|
||||
clickDropdownItem('SDXL 1.0');
|
||||
await bulk.saveBulkBaseModel();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.models.bulkBaseModelUpdatePartial',
|
||||
{ success: 1, failed: 1 },
|
||||
'warning'
|
||||
);
|
||||
});
|
||||
|
||||
it('destroys the picker and clears the staged value on cleanup', async () => {
|
||||
const bulk = await createBulkManager();
|
||||
stateStub.selectedModels.add('/models/a.safetensors');
|
||||
|
||||
bulk.showBulkBaseModelModal();
|
||||
clickDropdownItem('SDXL 1.0');
|
||||
expect(bulk.bulkBaseModelValue).toBe('SDXL 1.0');
|
||||
|
||||
bulk.cleanupBulkBaseModelModal();
|
||||
|
||||
expect(bulk.bulkBaseModelPicker).toBeNull();
|
||||
expect(bulk.bulkBaseModelValue).toBe('');
|
||||
expect(getPickerContainer().innerHTML).toBe('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user