mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
feat(frontend): enable downloads on Other page and default_other_roots settings UI
This commit is contained in:
@@ -56,6 +56,7 @@ vi.mock(SUMMARY_MODULE, () => ({
|
||||
}));
|
||||
|
||||
const { DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE);
|
||||
const { state } = await import(STATE_MODULE);
|
||||
|
||||
describe('DownloadManager._resolveIsDiffusionModel', () => {
|
||||
let manager;
|
||||
@@ -144,3 +145,185 @@ describe('DownloadManager._resolveIsDiffusionModel', () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DownloadManager._resolveOtherSubType', () => {
|
||||
let manager;
|
||||
let fetchMock;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new DownloadManager();
|
||||
manager.apiClient = { modelType: 'other' };
|
||||
manager.selectedFile = null;
|
||||
manager.selectedFiles = [];
|
||||
manager.currentVersion = null;
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function mockRoutingResponse(data, ok = true) {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok,
|
||||
status: ok ? 200 : 500,
|
||||
json: async () => data,
|
||||
});
|
||||
}
|
||||
|
||||
it('returns the backend sub_type for other downloads', async () => {
|
||||
manager.currentVersion = { baseModel: 'Flux.1 D', files: [{ type: 'VAE' }] };
|
||||
mockRoutingResponse({ success: true, root_kind: 'other', sub_type: 'vae' });
|
||||
|
||||
expect(await manager._resolveOtherSubType()).toBe('vae');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/lm/download/routing', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model_type: 'other',
|
||||
base_model: 'Flux.1 D',
|
||||
file_types: ['VAE'],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('sends selected_file_type plus all version file types when a file is selected', async () => {
|
||||
manager.currentVersion = { baseModel: 'Flux.1 D', files: [{ type: 'Model' }, { type: 'VAE' }] };
|
||||
manager.selectedFile = { type: 'VAE' };
|
||||
mockRoutingResponse({ success: true, root_kind: 'other', sub_type: 'vae' });
|
||||
|
||||
expect(await manager._resolveOtherSubType()).toBe('vae');
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.selected_file_type).toBe('VAE');
|
||||
expect(body.file_types).toEqual(['Model', 'VAE']);
|
||||
});
|
||||
|
||||
it('omits selected_file_type when no file is selected', async () => {
|
||||
manager.currentVersion = { baseModel: 'Flux.1 D', files: [{ type: 'Model' }, { type: 'VAE' }] };
|
||||
mockRoutingResponse({ success: true, root_kind: 'other', sub_type: 'vae' });
|
||||
|
||||
await manager._resolveOtherSubType();
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect('selected_file_type' in body).toBe(false);
|
||||
expect(body.file_types).toEqual(['Model', 'VAE']);
|
||||
});
|
||||
|
||||
it('returns null when the backend cannot decide a sub_type', async () => {
|
||||
manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'Model' }] };
|
||||
mockRoutingResponse({ success: true, root_kind: 'other', sub_type: null });
|
||||
|
||||
expect(await manager._resolveOtherSubType()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the endpoint fails', async () => {
|
||||
manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'VAE' }] };
|
||||
fetchMock.mockRejectedValue(new Error('network down'));
|
||||
|
||||
expect(await manager._resolveOtherSubType()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on a non-ok response', async () => {
|
||||
manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'VAE' }] };
|
||||
mockRoutingResponse({}, false);
|
||||
|
||||
expect(await manager._resolveOtherSubType()).toBeNull();
|
||||
});
|
||||
|
||||
it('never calls the endpoint for non-other pages', async () => {
|
||||
manager.apiClient = { modelType: 'loras' };
|
||||
manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'VAE' }] };
|
||||
|
||||
expect(await manager._resolveOtherSubType()).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never calls the endpoint without version metadata (e.g. Hugging Face)', async () => {
|
||||
expect(await manager._resolveOtherSubType()).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DownloadManager.proceedToLocationContent (other page)', () => {
|
||||
let manager;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<select id="modelRoot"></select>
|
||||
<label id="modelRootLabel"></label>
|
||||
<input id="folderPath" />
|
||||
`;
|
||||
state.global.settings = {};
|
||||
|
||||
manager = new DownloadManager();
|
||||
manager.apiClient = {
|
||||
modelType: 'other',
|
||||
apiConfig: { config: { displayName: 'Other Model' } },
|
||||
fetchModelRoots: vi.fn(),
|
||||
};
|
||||
manager.selectedFile = null;
|
||||
manager.selectedFiles = [];
|
||||
manager.currentVersion = { baseModel: 'Flux.1 D', files: [{ type: 'VAE' }] };
|
||||
manager.initializeFolderTree = vi.fn().mockResolvedValue();
|
||||
manager.folderTreeManager = { init: vi.fn() };
|
||||
manager.loadDefaultPathSetting = vi.fn();
|
||||
manager.updateTargetPath = vi.fn();
|
||||
vi.spyOn(manager, '_resolveIsDiffusionModel').mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it('fetches sub_type roots and preselects the configured default root', async () => {
|
||||
vi.spyOn(manager, '_resolveOtherSubType').mockResolvedValue('vae');
|
||||
manager.apiClient.fetchModelRoots.mockResolvedValue({
|
||||
success: true,
|
||||
roots: ['/models/vae-a', '/models/vae-b'],
|
||||
});
|
||||
state.global.settings.default_other_roots = { vae: '/models/vae-b' };
|
||||
|
||||
await manager.proceedToLocationContent();
|
||||
|
||||
expect(manager.apiClient.fetchModelRoots).toHaveBeenCalledWith('vae');
|
||||
const modelRoot = document.getElementById('modelRoot');
|
||||
expect(Array.from(modelRoot.options).map(o => o.value)).toEqual([
|
||||
'/models/vae-a',
|
||||
'/models/vae-b',
|
||||
]);
|
||||
expect(modelRoot.value).toBe('/models/vae-b');
|
||||
});
|
||||
|
||||
it('lists all other roots for manual selection when the sub_type is undecidable', async () => {
|
||||
vi.spyOn(manager, '_resolveOtherSubType').mockResolvedValue(null);
|
||||
manager.apiClient.fetchModelRoots.mockResolvedValue({
|
||||
success: true,
|
||||
roots: ['/models/vae', '/models/upscale'],
|
||||
});
|
||||
state.global.settings.default_other_roots = { vae: '/models/upscale' };
|
||||
|
||||
await manager.proceedToLocationContent();
|
||||
|
||||
// No argument: the merged /api/lm/other/roots list
|
||||
expect(manager.apiClient.fetchModelRoots).toHaveBeenCalledWith();
|
||||
const modelRoot = document.getElementById('modelRoot');
|
||||
expect(Array.from(modelRoot.options).map(o => o.value)).toEqual([
|
||||
'/models/vae',
|
||||
'/models/upscale',
|
||||
]);
|
||||
// Without a resolved sub_type no default_other_roots entry applies,
|
||||
// so the first option stays selected even though a vae default exists.
|
||||
expect(modelRoot.value).toBe('/models/vae');
|
||||
});
|
||||
|
||||
it('leaves the first root selected when no default is configured for the sub_type', async () => {
|
||||
vi.spyOn(manager, '_resolveOtherSubType').mockResolvedValue('upscaler');
|
||||
manager.apiClient.fetchModelRoots.mockResolvedValue({
|
||||
success: true,
|
||||
roots: ['/models/upscale'],
|
||||
});
|
||||
state.global.settings.default_other_roots = { vae: '/models/vae-a' };
|
||||
|
||||
await manager.proceedToLocationContent();
|
||||
|
||||
expect(manager.apiClient.fetchModelRoots).toHaveBeenCalledWith('upscaler');
|
||||
expect(document.getElementById('modelRoot').value).toBe('/models/upscale');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock('../../../static/js/state/index.js', () => {
|
||||
},
|
||||
createDefaultSettings: () => ({
|
||||
language: 'en',
|
||||
default_other_roots: {},
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -502,6 +503,182 @@ describe('SettingsManager library controls', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsManager other-model root selects', () => {
|
||||
const appendOtherRootSelects = (...subTypes) => {
|
||||
const selects = {};
|
||||
subTypes.forEach((subType) => {
|
||||
const select = document.createElement('select');
|
||||
select.dataset.otherRootSubtype = subType;
|
||||
document.body.appendChild(select);
|
||||
selects[subType] = select;
|
||||
});
|
||||
return selects;
|
||||
};
|
||||
|
||||
describe('loadOtherRoots', () => {
|
||||
it('populates each sub_type select from the grouped roots and preselects defaults', async () => {
|
||||
const manager = createManager();
|
||||
const selects = appendOtherRootSelects('vae', 'upscaler');
|
||||
selects.vae.disabled = true;
|
||||
|
||||
state.global.settings = {
|
||||
default_other_roots: { vae: '/models/vae-b' },
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
roots_by_subtype: {
|
||||
vae: ['/models/vae-a', '/models/vae-b'],
|
||||
upscaler: ['/models/upscale'],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await manager.loadOtherRoots();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/other/roots_by_subtype');
|
||||
expect(Array.from(selects.vae.options).map(o => o.value)).toEqual([
|
||||
'/models/vae-a',
|
||||
'/models/vae-b',
|
||||
]);
|
||||
expect(selects.vae.value).toBe('/models/vae-b');
|
||||
expect(selects.vae.disabled).toBe(false);
|
||||
expect(Array.from(selects.upscaler.options).map(o => o.value)).toEqual([
|
||||
'/models/upscale',
|
||||
]);
|
||||
// No configured default: first root wins
|
||||
expect(selects.upscaler.value).toBe('/models/upscale');
|
||||
expect(showToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a placeholder on selects whose sub_type has no roots', async () => {
|
||||
const manager = createManager();
|
||||
const selects = appendOtherRootSelects('vae', 'controlnet');
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
roots_by_subtype: { vae: ['/models/vae-a'] },
|
||||
}),
|
||||
});
|
||||
|
||||
await manager.loadOtherRoots();
|
||||
|
||||
expect(selects.controlnet.options).toHaveLength(1);
|
||||
expect(selects.controlnet.options[0].value).toBe('');
|
||||
expect(selects.controlnet.options[0].textContent).toBe('No Default');
|
||||
expect(selects.controlnet.disabled).toBe(true);
|
||||
expect(selects.vae.disabled).toBe(false);
|
||||
expect(showToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an error toast and placeholders when the request fails', async () => {
|
||||
const manager = createManager();
|
||||
const selects = appendOtherRootSelects('vae', 'upscaler');
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
});
|
||||
|
||||
await manager.loadOtherRoots();
|
||||
|
||||
expect(selects.vae.disabled).toBe(true);
|
||||
expect(selects.upscaler.disabled).toBe(true);
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
'toast.settings.otherRootsFailed',
|
||||
expect.objectContaining({ message: expect.any(String) }),
|
||||
'error',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call the API when no sub_type selects exist', async () => {
|
||||
const manager = createManager();
|
||||
global.fetch = vi.fn();
|
||||
|
||||
await manager.loadOtherRoots();
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveOtherRootSetting', () => {
|
||||
it('read-modify-writes the default_other_roots dict and posts it whole', async () => {
|
||||
const manager = createManager();
|
||||
state.global.settings = {
|
||||
default_other_roots: { vae: '/models/vae-a' },
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
await manager.saveOtherRootSetting('upscaler', '/models/upscale');
|
||||
|
||||
expect(state.global.settings.default_other_roots).toEqual({
|
||||
vae: '/models/vae-a',
|
||||
upscaler: '/models/upscale',
|
||||
});
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
default_other_roots: {
|
||||
vae: '/models/vae-a',
|
||||
upscaler: '/models/upscale',
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
'toast.settings.settingsUpdated',
|
||||
expect.objectContaining({ setting: expect.any(String) }),
|
||||
'success',
|
||||
);
|
||||
});
|
||||
|
||||
it('removes the sub_type key when the value is empty', async () => {
|
||||
const manager = createManager();
|
||||
state.global.settings = {
|
||||
default_other_roots: { vae: '/models/vae-a' },
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
await manager.saveOtherRootSetting('vae', '');
|
||||
|
||||
expect(state.global.settings.default_other_roots).toEqual({});
|
||||
expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({
|
||||
default_other_roots: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an error toast when the backend save fails', async () => {
|
||||
const manager = createManager();
|
||||
state.global.settings = { default_other_roots: {} };
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
});
|
||||
|
||||
await manager.saveOtherRootSetting('vae', '/models/vae-a');
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
'toast.settings.settingSaveFailed',
|
||||
expect.objectContaining({ message: expect.any(String) }),
|
||||
'error',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsManager recipes layout switch', () => {
|
||||
it('dispatches lm:recipes-layout-changed without recalculating the old scroller', async () => {
|
||||
const manager = createManager();
|
||||
|
||||
Reference in New Issue
Block a user