import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; vi.mock('../../../static/js/managers/ModalManager.js', () => ({ modalManager: { closeModal: vi.fn(), showModal: vi.fn(), }, })); vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ showToast: vi.fn(), })); vi.mock('../../../static/js/state/index.js', () => { return { state: { global: { settings: {}, }, }, createDefaultSettings: () => ({ language: 'en', standalone_mode: false, folder_paths: {}, folder_path_schema: [], }), }; }); vi.mock('../../../static/js/api/modelApiFactory.js', () => ({ resetAndReload: vi.fn(), })); vi.mock('../../../static/js/utils/constants.js', () => ({ DOWNLOAD_PATH_TEMPLATES: {}, DEFAULT_PATH_TEMPLATES: {}, MAPPABLE_BASE_MODELS: [], PATH_TEMPLATE_PLACEHOLDERS: {}, DEFAULT_PRIORITY_TAG_CONFIG: {}, getMappableBaseModelsDynamic: () => [], })); vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({ translate: (_key, _params, fallback) => fallback ?? '', })); vi.mock('../../../static/js/i18n/index.js', () => ({ i18n: { getCurrentLocale: () => 'en', setLanguage: vi.fn().mockResolvedValue(), }, })); vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({ configureModelCardVideo: vi.fn(), })); import { SettingsManager } from '../../../static/js/managers/SettingsManager.js'; import { bannerService } from '../../../static/js/managers/BannerService.js'; import { state } from '../../../static/js/state/index.js'; const CORE_SCHEMA = [ { key: 'loras', category: 'core', sub_type: null }, { key: 'checkpoints', category: 'core', sub_type: null }, { key: 'unet', category: 'core', sub_type: null }, { key: 'embeddings', category: 'core', sub_type: null }, ]; const OTHER_SCHEMA = [ { key: 'vae', category: 'other', sub_type: 'vae' }, { key: 'controlnet', category: 'other', sub_type: 'controlnet' }, ]; const createManager = () => { const initSettingsSpy = vi .spyOn(SettingsManager.prototype, 'initializeSettings') .mockResolvedValue(); const initializeSpy = vi .spyOn(SettingsManager.prototype, 'initialize') .mockImplementation(() => {}); const manager = new SettingsManager(); initSettingsSpy.mockRestore(); initializeSpy.mockRestore(); return manager; }; const buildModalDom = () => { document.body.innerHTML = `
`; }; const setStandaloneSettings = (overrides = {}) => { state.global.settings = { standalone_mode: true, folder_paths: {}, folder_path_schema: [...CORE_SCHEMA, ...OTHER_SCHEMA], enable_other_models: false, enabled_other_sub_types: [], ...overrides, }; }; beforeEach(() => { document.body.innerHTML = ''; vi.clearAllMocks(); bannerService.banners.clear(); }); afterEach(() => { delete global.fetch; }); describe('SettingsManager Model Paths section', () => { it('does not create the section in plugin mode', () => { buildModalDom(); state.global.settings = { standalone_mode: false }; const manager = createManager(); manager.setupModelPathsSection(); expect(document.querySelector('.settings-nav-item[data-section="modelPaths"]')).toBeNull(); expect(document.getElementById('section-modelPaths')).toBeNull(); }); it('creates nav item and section in standalone mode', () => { buildModalDom(); setStandaloneSettings(); const manager = createManager(); manager.setupModelPathsSection(); expect(document.querySelector('.settings-nav-item[data-section="modelPaths"]')).not.toBeNull(); expect(document.getElementById('section-modelPaths')).not.toBeNull(); }); it('switches sections when the nav item is clicked', () => { buildModalDom(); setStandaloneSettings(); const manager = createManager(); manager.setupModelPathsSection(); document.querySelector('.settings-nav-item[data-section="modelPaths"]').click(); expect(document.getElementById('section-modelPaths').classList.contains('active')).toBe(true); expect(document.getElementById('section-general').classList.contains('active')).toBe(false); }); it('static nav clicks clear the Model Paths active state (regression)', () => { buildModalDom(); setStandaloneSettings(); const manager = createManager(); manager.setupModelPathsSection(); // Static nav items were bound before the Model Paths button existed; // their handler must still clear its active state. manager.initializeNavigation(); const modelPathsNav = document.querySelector('.settings-nav-item[data-section="modelPaths"]'); modelPathsNav.click(); expect(modelPathsNav.classList.contains('active')).toBe(true); document.querySelector('.settings-nav-item[data-section="general"]').click(); expect(modelPathsNav.classList.contains('active')).toBe(false); expect(document.getElementById('section-modelPaths').classList.contains('active')).toBe(false); expect(document.getElementById('section-general').classList.contains('active')).toBe(true); }); it('renders core editors and only enabled other-model editors', () => { buildModalDom(); setStandaloneSettings({ enable_other_models: true, enabled_other_sub_types: ['vae'], folder_paths: { loras: ['/models/loras'] }, }); const manager = createManager(); manager.setupModelPathsSection(); manager.loadModelPaths(); // Core editors always rendered CORE_SCHEMA.forEach(({ key }) => { expect(document.getElementById(`modelFolderPaths-${key}`)).not.toBeNull(); }); // Only the enabled other-model sub-type is rendered expect(document.getElementById('modelFolderPaths-vae')).not.toBeNull(); expect(document.getElementById('modelFolderPaths-controlnet')).toBeNull(); expect(document.getElementById('modelPathsOtherEmpty').style.display).toBe('none'); // Existing values populate rows const loraInput = document.querySelector('#modelFolderPaths-loras .extra-folder-path-input'); expect(loraInput.value).toBe('/models/loras'); }); it('shows the empty hint when no other-model types are enabled', () => { buildModalDom(); setStandaloneSettings(); const manager = createManager(); manager.setupModelPathsSection(); manager.loadModelPaths(); expect(document.getElementById('modelPathsOtherEmpty').style.display).toBe('block'); expect(document.getElementById('modelFolderPaths-vae')).toBeNull(); }); it('renders inline enable controls synced with current settings', () => { buildModalDom(); setStandaloneSettings({ enable_other_models: true, enabled_other_sub_types: ['vae'], }); const manager = createManager(); manager.setupModelPathsSection(); manager.loadModelPaths(); const master = document.getElementById('modelPathsEnableOtherModels'); expect(master).not.toBeNull(); expect(master.checked).toBe(true); const vaeBox = document.querySelector('[data-model-paths-subtype="vae"]'); const controlnetBox = document.querySelector('[data-model-paths-subtype="controlnet"]'); expect(vaeBox.checked).toBe(true); expect(vaeBox.disabled).toBe(false); expect(controlnetBox.checked).toBe(false); }); it('inline master toggle saves the setting and re-renders editors', async () => { buildModalDom(); setStandaloneSettings({ enable_other_models: true, enabled_other_sub_types: ['vae'], }); const manager = createManager(); manager.saveSetting = vi.fn().mockImplementation(async (key, value) => { state.global.settings[key] = value; }); manager.loadOtherRoots = vi.fn().mockResolvedValue(); manager.setupModelPathsSection(); manager.loadModelPaths(); expect(document.getElementById('modelFolderPaths-vae')).not.toBeNull(); const master = document.getElementById('modelPathsEnableOtherModels'); master.checked = false; await manager.handleModelPathsEnableOtherModels(); expect(manager.saveSetting).toHaveBeenCalledWith('enable_other_models', false); expect(state.global.settings.enable_other_models).toBe(false); // Editors removed in place, empty hint back expect(document.getElementById('modelFolderPaths-vae')).toBeNull(); expect(document.getElementById('modelPathsOtherEmpty').style.display).toBe('block'); }); it('inline sub-type checkboxes save the allow-list and re-render editors', async () => { buildModalDom(); setStandaloneSettings({ enable_other_models: true, enabled_other_sub_types: ['vae'], }); const manager = createManager(); manager.saveSetting = vi.fn().mockImplementation(async (key, value) => { state.global.settings[key] = value; }); manager.loadOtherRoots = vi.fn().mockResolvedValue(); manager.setupModelPathsSection(); manager.loadModelPaths(); document.querySelector('[data-model-paths-subtype="controlnet"]').checked = true; await manager.handleModelPathsSubTypeToggles(); expect(manager.saveSetting).toHaveBeenCalledWith('enabled_other_sub_types', ['vae', 'controlnet']); expect(document.getElementById('modelFolderPaths-controlnet')).not.toBeNull(); }); it('saves collected folder paths via saveSetting', async () => { buildModalDom(); setStandaloneSettings(); const manager = createManager(); manager.saveSetting = vi.fn().mockResolvedValue(); manager.setupModelPathsSection(); manager.loadModelPaths(); // No rows exist until the user clicks Add expect(document.querySelector('#modelFolderPaths-loras .extra-folder-path-input')).toBeNull(); manager.addModelFolderPathRow('loras'); document.querySelector('#modelFolderPaths-loras .extra-folder-path-input').value = '/data/loras'; await manager.updateModelFolderPaths('loras'); expect(manager.saveSetting).toHaveBeenCalledWith('folder_paths', { loras: ['/data/loras'], checkpoints: [], unet: [], embeddings: [], }); expect(state.global.settings.folder_paths.loras).toEqual(['/data/loras']); }); it('blocks saving when checkpoints and unet share a path', async () => { buildModalDom(); setStandaloneSettings(); const manager = createManager(); manager.saveSetting = vi.fn().mockResolvedValue(); manager.setupModelPathsSection(); manager.loadModelPaths(); manager.addModelFolderPathRow('checkpoints'); manager.addModelFolderPathRow('unet'); document.querySelector('#modelFolderPaths-checkpoints .extra-folder-path-input').value = '/same/dir'; document.querySelector('#modelFolderPaths-unet .extra-folder-path-input').value = '/same/dir'; await manager.updateModelFolderPaths('checkpoints'); expect(manager.saveSetting).not.toHaveBeenCalled(); const ckptInput = document.querySelector('#modelFolderPaths-checkpoints .extra-folder-path-input'); expect(ckptInput.classList.contains('has-error')).toBe(true); }); it('appends a trailing empty row after filling one, but not after a removal', async () => { buildModalDom(); setStandaloneSettings({ folder_paths: { loras: ['/data/a'] } }); const manager = createManager(); manager.saveSetting = vi.fn().mockResolvedValue(); manager.setupModelPathsSection(); manager.loadModelPaths(); // Fill the trailing empty row -> save appends a fresh empty row manager.addModelFolderPathRow('loras'); const rows = () => document.querySelectorAll('#modelFolderPaths-loras .extra-folder-path-row'); expect(rows()).toHaveLength(2); rows()[1].querySelector('.extra-folder-path-input').value = '/data/b'; await manager.updateModelFolderPaths('loras'); expect(rows()).toHaveLength(3); // Removing a row never resurrects an empty row const removeBtn = rows()[0].querySelector('.remove-path-btn'); manager.removeModelFolderPathRow(removeBtn, 'loras'); await vi.waitFor(() => { expect(manager.saveSetting).toHaveBeenCalledTimes(2); }); // Two rows left: the saved '/data/b' plus the pre-existing trailing // empty row — removal must not append yet another empty row. expect(rows()).toHaveLength(2); const emptyRows = Array.from(rows()).filter( (row) => row.querySelector('.extra-folder-path-input').value === '', ); expect(emptyRows).toHaveLength(1); }); it('restores previous state when saving fails', async () => { buildModalDom(); setStandaloneSettings({ folder_paths: { loras: ['/original'] } }); const manager = createManager(); manager.saveSetting = vi.fn().mockRejectedValue(new Error('nope')); manager.setupModelPathsSection(); manager.loadModelPaths(); const input = document.querySelector('#modelFolderPaths-loras .extra-folder-path-input'); input.value = '/changed'; await manager.updateModelFolderPaths('loras'); expect(state.global.settings.folder_paths).toEqual({ loras: ['/original'] }); // Rows reloaded from restored state const reloaded = document.querySelector('#modelFolderPaths-loras .extra-folder-path-input'); expect(reloaded.value).toBe('/original'); }); it('marks pending-restart cues after a successful save', async () => { buildModalDom(); setStandaloneSettings(); const manager = createManager(); manager.saveSetting = vi.fn().mockResolvedValue(); manager.setupModelPathsSection(); manager.loadModelPaths(); const navItem = document.querySelector('.settings-nav-item[data-section="modelPaths"]'); expect(navItem.classList.contains('has-pending-restart')).toBe(false); manager.addModelFolderPathRow('loras'); document.querySelector('#modelFolderPaths-loras .extra-folder-path-input').value = '/data/loras'; await manager.updateModelFolderPaths('loras'); expect(navItem.classList.contains('has-pending-restart')).toBe(true); expect(document.getElementById('modelPathsRestartNotice').classList.contains('visible')).toBe(true); // A unique-per-change banner id: dismissing it once must not mute // future reminders (dismissed ids persist across restarts). const restartBanners = Array.from(bannerService.banners.keys()) .filter((id) => id.startsWith('model-paths-restart-')); expect(restartBanners).toHaveLength(1); }); });