mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
feat(settings): editable model library paths for standalone mode
Standalone users previously had to hand-edit settings.json to configure primary folder_paths. Add a standalone-only Model Paths section to the settings modal: - Backend exposes standalone_mode, folder_paths (with template placeholder values filtered out) and a data-driven folder_path_schema derived from OTHER_MODEL_FOLDER_SUBTYPES via GET /api/lm/settings - The new section renders multi-path editors per model type from the schema, with inline enable_other_models / sub-type controls so other model types are configured without leaving the tab - Persistent restart-required cues after a save: nav dot, inline notice and a global banner (unique id per change so dismissals don't mute future reminders) - The missing-model-paths startup banner and the Other Models no-paths empty state now deep-link into the new section instead of pointing at settings.json
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
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 = `
|
||||
<nav class="settings-nav">
|
||||
<ul class="settings-nav-list">
|
||||
<li class="settings-nav-group">
|
||||
<button type="button" class="settings-nav-item active" data-section="general">General</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="settings-form">
|
||||
<div class="settings-section active" id="section-general" data-section="general"></div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ describe('Other Models disabled page', () => {
|
||||
document.body.innerHTML = [
|
||||
'<button id="enableOtherModelsBtn"></button>',
|
||||
'<button id="openOtherModelsSettingsBtn"></button>',
|
||||
'<button id="openModelPathsSettingsBtn"></button>',
|
||||
'<button id="openSettingsFolderBtn"></button>',
|
||||
].join('');
|
||||
|
||||
@@ -65,6 +66,27 @@ describe('Other Models disabled page', () => {
|
||||
expect(showModal).toHaveBeenCalledWith('settingsModal');
|
||||
});
|
||||
|
||||
it('opens the Model Paths settings from the standalone no-folders state', async () => {
|
||||
const showModal = vi.fn();
|
||||
window.modalManager = { showModal };
|
||||
|
||||
const navItem = document.createElement('button');
|
||||
navItem.className = 'settings-nav-item';
|
||||
navItem.dataset.section = 'modelPaths';
|
||||
const navClick = vi.fn();
|
||||
navItem.addEventListener('click', navClick);
|
||||
document.body.appendChild(navItem);
|
||||
|
||||
document.getElementById('openModelPathsSettingsBtn').dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true }),
|
||||
);
|
||||
|
||||
expect(showModal).toHaveBeenCalledWith('settingsModal');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
expect(navClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reveals the settings.json location from the standalone no-folders state', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
'language': 'en',
|
||||
'llm_api_key_set': False,
|
||||
'other_models_paths_available': False,
|
||||
'standalone_mode': False,
|
||||
'theme': 'dark',
|
||||
}),
|
||||
'success': True,
|
||||
|
||||
@@ -160,3 +160,134 @@ async def test_activate_library_unexpected_error_returns_500(monkeypatch):
|
||||
assert response.status == 500
|
||||
assert payload["success"] is False
|
||||
assert payload["error"] == "bad things"
|
||||
|
||||
|
||||
class DummySettingsForGet:
|
||||
def __init__(self, values=None):
|
||||
self._values = dict(values or {})
|
||||
self.settings_file = "/tmp/settings.json"
|
||||
self.set_calls = []
|
||||
|
||||
def keys(self):
|
||||
return self._values.keys()
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._values.get(key, default)
|
||||
|
||||
def set(self, key, value):
|
||||
self.set_calls.append((key, value))
|
||||
self._values[key] = value
|
||||
|
||||
def get_startup_messages(self):
|
||||
return []
|
||||
|
||||
|
||||
def make_get_handler(values=None) -> SettingsHandler:
|
||||
return SettingsHandler(
|
||||
settings_service=DummySettingsForGet(values),
|
||||
metadata_provider_updater=noop_async,
|
||||
downloader_factory=dummy_downloader_factory,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patch_other_models_availability(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"get_other_models_availability",
|
||||
lambda: {"available": False},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_settings_plugin_mode_hides_folder_paths(
|
||||
monkeypatch, patch_other_models_availability
|
||||
):
|
||||
monkeypatch.delenv("LORA_MANAGER_STANDALONE", raising=False)
|
||||
handler = make_get_handler(
|
||||
{
|
||||
"language": "en",
|
||||
"folder_paths": {"loras": ["/models/loras"]},
|
||||
}
|
||||
)
|
||||
|
||||
response = await handler.get_settings(FakeRequest())
|
||||
payload = json_payload(response)
|
||||
|
||||
assert response.status == 200
|
||||
settings = payload["settings"]
|
||||
assert settings["standalone_mode"] is False
|
||||
assert "folder_paths" not in settings
|
||||
assert "folder_path_schema" not in settings
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_settings_standalone_exposes_folder_paths_and_schema(
|
||||
monkeypatch, patch_other_models_availability
|
||||
):
|
||||
monkeypatch.setenv("LORA_MANAGER_STANDALONE", "1")
|
||||
folder_paths = {"loras": ["/models/loras"], "vae": ["/models/vae"]}
|
||||
handler = make_get_handler({"language": "en", "folder_paths": folder_paths})
|
||||
|
||||
response = await handler.get_settings(FakeRequest())
|
||||
payload = json_payload(response)
|
||||
|
||||
assert response.status == 200
|
||||
settings = payload["settings"]
|
||||
assert settings["standalone_mode"] is True
|
||||
assert settings["folder_paths"] == folder_paths
|
||||
|
||||
schema = settings["folder_path_schema"]
|
||||
core_keys = [entry["key"] for entry in schema if entry["category"] == "core"]
|
||||
assert core_keys == ["loras", "checkpoints", "unet", "embeddings"]
|
||||
other_entries = {entry["key"]: entry for entry in schema if entry["category"] == "other"}
|
||||
assert other_entries["vae"]["sub_type"] == "vae"
|
||||
assert other_entries["text_encoders"]["sub_type"] == "text_encoder"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_settings_passes_folder_paths_through(
|
||||
monkeypatch, patch_other_models_availability
|
||||
):
|
||||
monkeypatch.setenv("LORA_MANAGER_STANDALONE", "1")
|
||||
handler = make_get_handler({"folder_paths": {}})
|
||||
new_paths = {"loras": ["/models/loras"]}
|
||||
|
||||
response = await handler.update_settings(
|
||||
FakeRequest(json_data={"folder_paths": new_paths})
|
||||
)
|
||||
payload = json_payload(response)
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert handler._settings.set_calls == [("folder_paths", new_paths)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_settings_standalone_filters_template_placeholders(
|
||||
monkeypatch, patch_other_models_availability
|
||||
):
|
||||
"""Fresh installs are seeded from settings.json.example; its placeholder
|
||||
paths must not show up as real values in the Model Paths UI."""
|
||||
monkeypatch.setenv("LORA_MANAGER_STANDALONE", "1")
|
||||
handler = make_get_handler(
|
||||
{
|
||||
"folder_paths": {
|
||||
"loras": ["C:/path/to/your/loras_folder", "/real/loras"],
|
||||
"vae": ["C:/path/to/another/vae_folder"],
|
||||
}
|
||||
}
|
||||
)
|
||||
handler._settings.get_template_folder_path_placeholders = lambda: {
|
||||
"C:/path/to/your/loras_folder",
|
||||
"C:/path/to/another/vae_folder",
|
||||
}
|
||||
|
||||
response = await handler.get_settings(FakeRequest())
|
||||
payload = json_payload(response)
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["settings"]["folder_paths"] == {
|
||||
"loras": ["/real/loras"],
|
||||
"vae": [],
|
||||
}
|
||||
|
||||
@@ -52,12 +52,18 @@ def test_missing_settings_creates_defaults_and_emits_warnings(tmp_path):
|
||||
|
||||
actions = warning.get("actions") or []
|
||||
assert actions == [
|
||||
{
|
||||
"action": "open-model-paths-settings",
|
||||
"label": "Configure model folders",
|
||||
"type": "primary",
|
||||
"icon": "fas fa-cog",
|
||||
},
|
||||
{
|
||||
"action": "open-settings-location",
|
||||
"label": "Open settings folder",
|
||||
"type": "primary",
|
||||
"icon": "fas fa-folder-open",
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -155,3 +161,13 @@ def test_apply_settings_dir_from_argv():
|
||||
os.environ.pop("LORA_MANAGER_SETTINGS_DIR", None)
|
||||
else:
|
||||
os.environ["LORA_MANAGER_SETTINGS_DIR"] = previous
|
||||
|
||||
|
||||
def test_template_folder_path_placeholders_are_exposed():
|
||||
manager = get_settings_manager()
|
||||
|
||||
placeholders = manager.get_template_folder_path_placeholders()
|
||||
|
||||
assert "C:/path/to/your/loras_folder" in placeholders
|
||||
assert "C:/path/to/another/embeddings_folder" in placeholders
|
||||
assert len(placeholders) == 8
|
||||
|
||||
@@ -42,4 +42,36 @@ class TestIsEmptyPlaceholderHash:
|
||||
|
||||
def test_rejects_non_strings(self):
|
||||
assert not is_empty_placeholder_hash(None)
|
||||
assert not is_empty_placeholder_hash(123)
|
||||
assert not is_empty_placeholder_hash(123)
|
||||
|
||||
class TestFolderPathSchema:
|
||||
def test_core_keys_first_in_canonical_order(self):
|
||||
from py.utils.constants import CORE_FOLDER_PATH_KEYS, folder_path_schema
|
||||
|
||||
schema = folder_path_schema()
|
||||
core = [entry for entry in schema if entry["category"] == "core"]
|
||||
|
||||
assert [entry["key"] for entry in core] == CORE_FOLDER_PATH_KEYS
|
||||
assert all(entry["sub_type"] is None for entry in core)
|
||||
assert schema[: len(core)] == core
|
||||
|
||||
def test_other_entries_derive_from_subtypes_table(self):
|
||||
from py.utils.constants import OTHER_MODEL_FOLDER_SUBTYPES, folder_path_schema
|
||||
|
||||
schema = folder_path_schema()
|
||||
other = {entry["key"]: entry for entry in schema if entry["category"] == "other"}
|
||||
|
||||
assert set(other) == set(OTHER_MODEL_FOLDER_SUBTYPES)
|
||||
for folder_key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items():
|
||||
assert other[folder_key]["sub_type"] == sub_type
|
||||
|
||||
def test_text_encoder_exposes_both_folder_keys(self):
|
||||
from py.utils.constants import folder_path_schema
|
||||
|
||||
text_encoder_keys = [
|
||||
entry["key"]
|
||||
for entry in folder_path_schema()
|
||||
if entry["sub_type"] == "text_encoder"
|
||||
]
|
||||
|
||||
assert text_encoder_keys == ["text_encoders", "clip"]
|
||||
|
||||
Reference in New Issue
Block a user