Files
Will Miaoandsmhc 8269cc7b66 feat(autocomplete): turn on listed LoRAs when picked from the dropdown
The "already added" cue was a single check, so a listed-but-disabled
LoRA looked identical to an enabled one, and picking either inserted a
duplicate tag that left the disabled entry off.

Show two states — a green "Added" pill and an amber "Off" pill — and
make the pick act on them: an added LoRA is enabled in place (reusing
the shared add-or-enable helper), an active one is left alone, and the
typed query is removed either way so no duplicate tag or stray search
text is left in the prompt.

Co-authored-by: smhc <6404304+smhc@users.noreply.github.com>
2026-11-07 20:20:17 +08:00

340 lines
11 KiB
JavaScript

import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
API_MODULE,
APP_MODULE,
CARET_HELPER_MODULE,
PREVIEW_COMPONENT_MODULE,
AUTOCOMPLETE_MODULE,
} = vi.hoisted(() => ({
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
CARET_HELPER_MODULE: new URL('../../../web/comfyui/textarea_caret_helper.js', import.meta.url).pathname,
PREVIEW_COMPONENT_MODULE: new URL('../../../web/comfyui/preview_tooltip.js', import.meta.url).pathname,
AUTOCOMPLETE_MODULE: new URL('../../../web/comfyui/autocomplete.js', import.meta.url).pathname,
}));
const fetchApiMock = vi.fn();
const settingGetMock = vi.fn();
const settingSetMock = vi.fn();
const caretHelperInstance = {
getBeforeCursor: vi.fn(() => ''),
getCursorOffset: vi.fn(() => ({ left: 0, top: 0 })),
};
const previewTooltipMock = {
show: vi.fn(),
hide: vi.fn(),
cleanup: vi.fn(),
};
vi.mock(API_MODULE, () => ({
api: {
fetchApi: fetchApiMock,
addEventListener: vi.fn(),
},
}));
vi.mock(APP_MODULE, () => ({
app: {
canvas: {
ds: { scale: 1 },
},
extensionManager: {
setting: {
get: settingGetMock,
set: settingSetMock,
},
},
registerExtension: vi.fn(),
},
}));
vi.mock(CARET_HELPER_MODULE, () => ({
TextAreaCaretHelper: vi.fn(() => caretHelperInstance),
}));
vi.mock(PREVIEW_COMPONENT_MODULE, () => ({
PreviewTooltip: vi.fn(() => previewTooltipMock),
}));
const PATH = 'portraits/example_entry.safetensors';
const TITLE = 'Golden Hour Portrait';
const respondWith = ({ paths = [PATH], items = null } = {}) => {
const payload = { success: true, relative_paths: paths };
if (items) {
payload.items = items;
}
return {
json: () => Promise.resolve(payload),
};
};
const mountAutocomplete = async ({ node } = {}) => {
const input = document.createElement('textarea');
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', {
debounceDelay: 0,
showPreview: false,
node,
});
input.value = 'example';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
return { input, autoComplete };
};
const itemEls = (autoComplete) =>
Array.from(autoComplete.dropdown.querySelectorAll('.comfy-autocomplete-item'));
describe('AutoComplete LoRA title matching and added cue', () => {
beforeEach(() => {
document.body.innerHTML = '';
document.head.querySelectorAll('style').forEach((styleEl) => styleEl.remove());
Element.prototype.scrollIntoView = vi.fn();
fetchApiMock.mockReset();
settingGetMock.mockReset();
settingSetMock.mockReset();
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.autocomplete_append_comma') return true;
if (key === 'loramanager.autocomplete_auto_format') return true;
if (key === 'loramanager.autocomplete_accept_key') return 'both';
if (key === 'loramanager.prompt_tag_autocomplete') return true;
if (key === 'loramanager.tag_space_replacement') return false;
if (key === 'loramanager.node_lora_name_display') return 'File Name';
return undefined;
});
caretHelperInstance.getBeforeCursor.mockReset();
caretHelperInstance.getCursorOffset.mockReset();
caretHelperInstance.getBeforeCursor.mockReturnValue('');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 0, top: 0 });
previewTooltipMock.show.mockReset();
previewTooltipMock.hide.mockReset();
previewTooltipMock.cleanup.mockReset();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('keeps the legacy request and rendering in File Name mode', async () => {
fetchApiMock.mockResolvedValue(respondWith());
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const { autoComplete } = await mountAutocomplete();
expect(fetchApiMock).toHaveBeenCalledWith(
'/lm/loras/relative-paths?search=example&limit=100',
);
const [item] = itemEls(autoComplete);
expect(item).toBeTruthy();
expect(item.querySelector('.lm-autocomplete-name').textContent).toBe(
'portraits/example_entry',
);
expect(item.querySelector('.lm-autocomplete-subtitle')).toBeNull();
expect(item.querySelector('.lm-autocomplete-added')).toBeNull();
});
it('requests and shows library titles in Model Name mode', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.node_lora_name_display') return 'Model Name';
return undefined;
});
fetchApiMock.mockResolvedValue(
respondWith({
items: [{ relative_path: PATH, model_name: TITLE }],
}),
);
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const { autoComplete } = await mountAutocomplete();
expect(fetchApiMock).toHaveBeenCalledWith(
'/lm/loras/relative-paths?search=example&limit=100&match_model_name=true&include_model_name=true',
);
const [item] = itemEls(autoComplete);
expect(item.querySelector('.lm-autocomplete-name').textContent).toBe(TITLE);
expect(item.querySelector('.lm-autocomplete-subtitle').textContent).toBe(
'portraits/example_entry',
);
});
it('falls back to the file name when the entry has no title', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.node_lora_name_display') return 'Model Name';
return undefined;
});
fetchApiMock.mockResolvedValue(
respondWith({
items: [{ relative_path: PATH, model_name: '' }],
}),
);
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const { autoComplete } = await mountAutocomplete();
const [item] = itemEls(autoComplete);
expect(item.querySelector('.lm-autocomplete-name').textContent).toBe(
'portraits/example_entry',
);
expect(item.querySelector('.lm-autocomplete-subtitle')).toBeNull();
});
it('marks an enabled LoRA as added', async () => {
fetchApiMock.mockResolvedValue(respondWith());
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const node = {
lorasWidget: {
value: [{ name: 'portraits/example_entry.safetensors', active: true }],
},
};
const { autoComplete } = await mountAutocomplete({ node });
const [item] = itemEls(autoComplete);
const cue = item.querySelector('.lm-autocomplete-added.is-active');
expect(cue).toBeTruthy();
expect(cue.textContent).toBe('✓Added');
expect(cue.title).toBe('Added to this node');
});
it('marks a disabled LoRA with the inactive cue', async () => {
fetchApiMock.mockResolvedValue(respondWith());
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const node = {
lorasWidget: {
value: [{ name: 'portraits/example_entry.safetensors', active: false }],
},
};
const { autoComplete } = await mountAutocomplete({ node });
const [item] = itemEls(autoComplete);
const cue = item.querySelector('.lm-autocomplete-added.is-inactive');
expect(cue).toBeTruthy();
expect(cue.textContent).toBe('○Off');
expect(cue.title).toBe('Added but turned off — select to enable');
});
it('matches legacy bare-name list entries by file name', async () => {
fetchApiMock.mockResolvedValue(respondWith());
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const node = { lorasWidget: { value: [{ name: 'example_entry' }] } };
const { autoComplete } = await mountAutocomplete({ node });
const [item] = itemEls(autoComplete);
expect(item.querySelector('.lm-autocomplete-added.is-active')).toBeTruthy();
});
it('does not mark a LoRA that is not on the node list', async () => {
fetchApiMock.mockResolvedValue(respondWith());
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const node = {
lorasWidget: { value: [{ name: 'styles/other_entry' }] },
};
const { autoComplete } = await mountAutocomplete({ node });
const [item] = itemEls(autoComplete);
expect(item.querySelector('.lm-autocomplete-added')).toBeNull();
});
it('falls back to parsing the textarea when the node has no LoRA list', async () => {
fetchApiMock.mockResolvedValue(respondWith());
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const node = {};
const { autoComplete, input } = await mountAutocomplete({ node });
input.value = 'masterpiece, <lora:portraits/example_entry:0.8>';
autoComplete.render();
const [item] = itemEls(autoComplete);
expect(item.querySelector('.lm-autocomplete-added.is-active')).toBeTruthy();
});
it('enables a listed but disabled LoRA instead of inserting a tag', async () => {
fetchApiMock.mockResolvedValue(respondWith());
caretHelperInstance.getBeforeCursor.mockReturnValue('pi');
const callback = vi.fn();
const node = {
lorasWidget: {
value: [
{ name: 'portraits/example_entry', strength: '0.8', active: false },
],
callback,
},
};
const { autoComplete, input } = await mountAutocomplete({ node });
input.value = 'pi';
input.setSelectionRange(2, 2);
itemEls(autoComplete)[0].dispatchEvent(
new MouseEvent('click', { bubbles: true }),
);
await Promise.resolve();
expect(node.lorasWidget.value[0].active).toBe(true);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback.mock.calls[0][0][0].active).toBe(true);
// The typed query is dropped, and no <lora:…> tag was inserted.
expect(input.value).toBe('');
});
it('leaves an already enabled LoRA alone instead of inserting a tag', async () => {
fetchApiMock.mockResolvedValue(respondWith());
caretHelperInstance.getBeforeCursor.mockReturnValue('pi');
const callback = vi.fn();
const node = {
lorasWidget: {
value: [{ name: 'portraits/example_entry', active: true }],
callback,
},
};
const { autoComplete, input } = await mountAutocomplete({ node });
input.value = 'pi';
input.setSelectionRange(2, 2);
itemEls(autoComplete)[0].dispatchEvent(
new MouseEvent('click', { bubbles: true }),
);
await Promise.resolve();
expect(callback).not.toHaveBeenCalled();
expect(input.value).toBe('');
});
it('does not request titles for non-LoRA autocompletes', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.node_lora_name_display') return 'Model Name';
return undefined;
});
fetchApiMock.mockResolvedValue({ json: () => Promise.resolve({ success: true, words: [] }) });
const input = document.createElement('textarea');
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
new AutoComplete(input, 'prompt', { debounceDelay: 0, showPreview: false });
input.value = 'example';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] || '';
expect(requestedUrl).not.toContain('match_model_name');
expect(requestedUrl).not.toContain('include_model_name');
});
});