mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-07 14:30:15 -03:00
Add /af and /noaf toggle commands (plus /activefilters aliases) to the
loras autocomplete widget. When enabled (default off), suggestions are
matched within the active filters (folder, base model, tags, auto-tags,
license, tag logic) persisted by the LoRA Manager page in localStorage,
keeping the match pool consistent with the list endpoint, including the
global show_only_sfw setting.
Backend: /lm/{prefix}/relative-paths accepts the filter query params and
pre-filters the scanner cache with ModelFilterSet. The presence of the
recursive param signals the filter pipeline to run even without concrete
filters so global settings stay in parity with the list endpoint.
2040 lines
72 KiB
JavaScript
2040 lines
72 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,
|
|
},
|
|
}));
|
|
|
|
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),
|
|
}));
|
|
|
|
describe('AutoComplete widget interactions', () => {
|
|
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;
|
|
}
|
|
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();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('fetches and renders search results when input exceeds the minimum characters', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
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 });
|
|
|
|
input.value = 'example';
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
await vi.runAllTimersAsync();
|
|
await Promise.resolve();
|
|
|
|
expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/relative-paths?search=example&limit=100');
|
|
const items = autoComplete.dropdown.querySelectorAll('.comfy-autocomplete-item');
|
|
expect(items).toHaveLength(1);
|
|
expect(autoComplete.dropdown.style.display).toBe('block');
|
|
expect(autoComplete.isVisible).toBe(true);
|
|
expect(caretHelperInstance.getCursorOffset).toHaveBeenCalled();
|
|
});
|
|
|
|
it('deduplicates duplicate-equivalent query variations before issuing requests', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({ success: true, words: [] }),
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('Example');
|
|
|
|
const input = document.createElement('textarea');
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
new AutoComplete(input, 'prompt', { debounceDelay: 0, showPreview: false, minChars: 1 });
|
|
|
|
input.value = 'Example';
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
await vi.runAllTimersAsync();
|
|
await Promise.resolve();
|
|
|
|
expect(fetchApiMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchApiMock).toHaveBeenCalledWith('/lm/custom-words/search?enriched=true&search=Example&limit=100');
|
|
});
|
|
|
|
it('inserts the selected LoRA with usage tip strengths and restores focus', async () => {
|
|
fetchApiMock.mockImplementation((url) => {
|
|
if (url.includes('usage-tips-by-path')) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
success: true,
|
|
usage_tips: JSON.stringify({ strength: '1.5', clip_strength: '0.9' }),
|
|
}),
|
|
});
|
|
}
|
|
|
|
return Promise.resolve({
|
|
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
|
|
});
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('alpha, example');
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = 'alpha, example';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'loras', { debounceDelay: 0, showPreview: false });
|
|
|
|
await autoComplete.insertSelection('models/example.safetensors');
|
|
|
|
expect(fetchApiMock).toHaveBeenCalledWith(
|
|
'/lm/loras/usage-tips-by-path?relative_path=models%2Fexample.safetensors',
|
|
);
|
|
expect(input.value).toContain('<lora:example:1.5:0.9>,');
|
|
expect(autoComplete.dropdown.style.display).toBe('none');
|
|
expect(input.focus).toHaveBeenCalled();
|
|
expect(input.setSelectionRange).toHaveBeenCalled();
|
|
});
|
|
|
|
it('accepts the selected suggestion with Tab', async () => {
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = 'example';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });
|
|
|
|
autoComplete.items = ['example_completion'];
|
|
autoComplete.selectedIndex = 0;
|
|
autoComplete.isVisible = true;
|
|
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
|
|
|
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
|
|
input.dispatchEvent(tabEvent);
|
|
|
|
expect(tabEvent.defaultPrevented).toBe(true);
|
|
expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
|
|
});
|
|
|
|
it('formats duplicate commas and extra spaces when the textarea loses focus', async () => {
|
|
const input = document.createElement('textarea');
|
|
input.value = 'foo bar, , baz ,, qux';
|
|
document.body.append(input);
|
|
|
|
const inputListener = vi.fn();
|
|
input.addEventListener('input', inputListener);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
new AutoComplete(input,'prompt', { showPreview: false });
|
|
|
|
input.dispatchEvent(new Event('blur', { bubbles: true }));
|
|
|
|
expect(input.value).toBe('foo bar, baz, qux');
|
|
expect(inputListener).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('skips blur formatting when autocomplete auto format is disabled', async () => {
|
|
settingGetMock.mockImplementation((key) => {
|
|
if (key === 'loramanager.autocomplete_append_comma') {
|
|
return true;
|
|
}
|
|
if (key === 'loramanager.autocomplete_auto_format') {
|
|
return false;
|
|
}
|
|
if (key === 'loramanager.autocomplete_accept_key') {
|
|
return 'both';
|
|
}
|
|
if (key === 'loramanager.prompt_tag_autocomplete') {
|
|
return true;
|
|
}
|
|
if (key === 'loramanager.tag_space_replacement') {
|
|
return false;
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = 'foo bar, , baz ,, qux';
|
|
document.body.append(input);
|
|
|
|
const inputListener = vi.fn();
|
|
input.addEventListener('input', inputListener);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
new AutoComplete(input,'prompt', { showPreview: false });
|
|
|
|
input.dispatchEvent(new Event('blur', { bubbles: true }));
|
|
|
|
expect(input.value).toBe('foo bar, , baz ,, qux');
|
|
expect(inputListener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows the full command list when typing a single slash', async () => {
|
|
const input = document.createElement('textarea');
|
|
input.value = '/';
|
|
input.selectionStart = input.value.length;
|
|
document.body.append(input);
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
|
|
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
const commandNames = autoComplete.items.map((item) => item.command);
|
|
|
|
expect(commandNames).toContain('/character');
|
|
expect(commandNames).toContain('/artist');
|
|
expect(commandNames).toContain('/general');
|
|
expect(commandNames).toContain('/copyright');
|
|
expect(commandNames).toContain('/meta');
|
|
expect(commandNames).toContain('/species');
|
|
expect(commandNames).toContain('/lore');
|
|
expect(commandNames).toContain('/emb');
|
|
expect(commandNames).toContain('/embedding');
|
|
expect(commandNames).toContain('/wildcard');
|
|
});
|
|
|
|
it('renders every command item when slash opens the command list', async () => {
|
|
const input = document.createElement('textarea');
|
|
input.value = '/';
|
|
input.selectionStart = input.value.length;
|
|
document.body.append(input);
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input, 'prompt', { showPreview: false, minChars: 1 });
|
|
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
const renderedCommands = autoComplete.contentContainer.querySelectorAll('.lm-autocomplete-command-name');
|
|
|
|
expect(renderedCommands).toHaveLength(autoComplete.items.length);
|
|
});
|
|
|
|
it('accepts the selected suggestion with Enter', async () => {
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = 'example';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });
|
|
|
|
autoComplete.items = ['example_completion'];
|
|
autoComplete.selectedIndex = 0;
|
|
autoComplete.isVisible = true;
|
|
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
|
|
|
const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
|
|
input.dispatchEvent(enterEvent);
|
|
|
|
expect(enterEvent.defaultPrevented).toBe(true);
|
|
expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
|
|
});
|
|
|
|
it('prefers the latest best match when Tab is pressed before debounced suggestions fully refresh', async () => {
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = 'loop';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
|
|
|
|
autoComplete.searchType = 'custom_words';
|
|
autoComplete.items = [
|
|
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1000 },
|
|
{ tag_name: 'loop', category: 0, post_count: 500 },
|
|
];
|
|
autoComplete.currentSearchTerm = 'loo';
|
|
autoComplete.selectedIndex = 0;
|
|
autoComplete.isVisible = true;
|
|
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
|
|
|
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
|
|
input.dispatchEvent(tabEvent);
|
|
|
|
expect(tabEvent.defaultPrevented).toBe(true);
|
|
expect(autoComplete.selectedIndex).toBe(1);
|
|
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
|
|
});
|
|
|
|
it('preserves manual ArrowDown selection when Tab accepts a suggestion', async () => {
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = 'loop';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
|
|
|
|
autoComplete.searchType = 'custom_words';
|
|
autoComplete.items = [
|
|
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1000 },
|
|
{ tag_name: 'loop', category: 0, post_count: 500 },
|
|
];
|
|
autoComplete.currentSearchTerm = 'loo';
|
|
autoComplete.selectedIndex = 0;
|
|
autoComplete.isVisible = true;
|
|
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
|
|
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
|
|
|
|
expect(autoComplete.selectedIndex).toBe(1);
|
|
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
|
|
});
|
|
|
|
it('preserves manual ArrowDown selection when Enter accepts a suggestion', async () => {
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = 'loop';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
|
|
|
|
autoComplete.searchType = 'custom_words';
|
|
autoComplete.items = [
|
|
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1000 },
|
|
{ tag_name: 'loop', category: 0, post_count: 500 },
|
|
];
|
|
autoComplete.currentSearchTerm = 'loo';
|
|
autoComplete.selectedIndex = 0;
|
|
autoComplete.isVisible = true;
|
|
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
|
|
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
|
|
|
|
expect(autoComplete.selectedIndex).toBe(1);
|
|
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
|
|
});
|
|
|
|
it('accepts the first available suggestion with Tab even if delayed auto-selection has not happened yet', async () => {
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = 'loop';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });
|
|
|
|
autoComplete.items = ['loop'];
|
|
autoComplete.selectedIndex = -1;
|
|
autoComplete.isVisible = true;
|
|
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
|
|
|
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
|
|
input.dispatchEvent(tabEvent);
|
|
|
|
expect(tabEvent.defaultPrevented).toBe(true);
|
|
expect(autoComplete.selectedIndex).toBe(0);
|
|
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
|
|
});
|
|
|
|
it('only accepts with Tab when autocomplete accept key is set to tab_only', async () => {
|
|
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 'tab_only';
|
|
}
|
|
if (key === 'loramanager.prompt_tag_autocomplete') {
|
|
return true;
|
|
}
|
|
if (key === 'loramanager.tag_space_replacement') {
|
|
return false;
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = 'example';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });
|
|
|
|
autoComplete.items = ['example_completion'];
|
|
autoComplete.selectedIndex = 0;
|
|
autoComplete.isVisible = true;
|
|
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
|
|
|
const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
|
|
input.dispatchEvent(enterEvent);
|
|
|
|
expect(enterEvent.defaultPrevented).toBe(false);
|
|
expect(insertSelectionSpy).not.toHaveBeenCalled();
|
|
|
|
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
|
|
input.dispatchEvent(tabEvent);
|
|
|
|
expect(tabEvent.defaultPrevented).toBe(true);
|
|
expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
|
|
});
|
|
|
|
it('only accepts with Enter when autocomplete accept key is set to enter_only', async () => {
|
|
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 'enter_only';
|
|
}
|
|
if (key === 'loramanager.prompt_tag_autocomplete') {
|
|
return true;
|
|
}
|
|
if (key === 'loramanager.tag_space_replacement') {
|
|
return false;
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = 'example';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });
|
|
|
|
autoComplete.items = ['example_completion'];
|
|
autoComplete.selectedIndex = 0;
|
|
autoComplete.isVisible = true;
|
|
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
|
|
|
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
|
|
input.dispatchEvent(tabEvent);
|
|
|
|
expect(tabEvent.defaultPrevented).toBe(false);
|
|
expect(insertSelectionSpy).not.toHaveBeenCalled();
|
|
|
|
const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
|
|
input.dispatchEvent(enterEvent);
|
|
|
|
expect(enterEvent.defaultPrevented).toBe(true);
|
|
expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
|
|
});
|
|
|
|
it('does not intercept Tab when the dropdown is not visible', async () => {
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = 'example';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });
|
|
|
|
autoComplete.items = ['example_completion'];
|
|
autoComplete.selectedIndex = 0;
|
|
autoComplete.isVisible = false;
|
|
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
|
|
|
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
|
|
input.dispatchEvent(tabEvent);
|
|
|
|
expect(tabEvent.defaultPrevented).toBe(false);
|
|
expect(insertSelectionSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('highlights multiple include tokens while ignoring excluded ones', async () => {
|
|
const input = document.createElement('textarea');
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'loras', { showPreview: false });
|
|
|
|
const highlighted = autoComplete.highlightMatch(
|
|
'models/flux/beta-detail.safetensors',
|
|
'flux detail -beta',
|
|
);
|
|
|
|
const highlightCount = (highlighted.match(/<span/g) || []).length;
|
|
expect(highlightCount).toBe(2);
|
|
expect(highlighted).toContain('flux');
|
|
expect(highlighted).toContain('detail');
|
|
expect(highlighted).not.toMatch(/beta<\/span>/i);
|
|
});
|
|
|
|
it('handles arrow key navigation with virtual scrolling', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const mockItems = Array.from({ length: 50 }, (_, i) => `model_${i.toString().padStart(2,'0')}.safetensors`);
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({ success: true, relative_paths: mockItems }),
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('model');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
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,
|
|
enableVirtualScroll: true,
|
|
itemHeight: 40,
|
|
visibleItems: 15,
|
|
pageSize: 20,
|
|
});
|
|
|
|
input.value = 'model';
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
await vi.runAllTimersAsync();
|
|
await Promise.resolve();
|
|
|
|
expect(autoComplete.items.length).toBeGreaterThan(0);
|
|
expect(autoComplete.selectedIndex).toBe(0);
|
|
|
|
const initialSelectedEl = autoComplete.contentContainer?.querySelector('.comfy-autocomplete-item-selected');
|
|
expect(initialSelectedEl).toBeDefined();
|
|
|
|
const arrowDownEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true });
|
|
input.dispatchEvent(arrowDownEvent);
|
|
|
|
expect(autoComplete.selectedIndex).toBe(1);
|
|
|
|
const secondSelectedEl = autoComplete.contentContainer?.querySelector('.comfy-autocomplete-item-selected');
|
|
expect(secondSelectedEl).toBeDefined();
|
|
expect(secondSelectedEl?.dataset.index).toBe('1');
|
|
|
|
const arrowUpEvent = new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true });
|
|
input.dispatchEvent(arrowUpEvent);
|
|
|
|
expect(autoComplete.selectedIndex).toBe(0);
|
|
|
|
const firstSelectedElAgain = autoComplete.contentContainer?.querySelector('.comfy-autocomplete-item-selected');
|
|
expect(firstSelectedElAgain).toBeDefined();
|
|
expect(firstSelectedElAgain?.dataset.index).toBe('0');
|
|
});
|
|
|
|
it('maintains selection when scrolling to invisible items', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const mockItems = Array.from({ length: 100 }, (_, i) => `item_${i.toString().padStart(3,'0')}.safetensors`);
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({ success: true, relative_paths: mockItems }),
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('item');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const input = document.createElement('textarea');
|
|
input.style.width = '400px';
|
|
input.style.height = '200px';
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'loras', {
|
|
debounceDelay: 0,
|
|
showPreview: false,
|
|
enableVirtualScroll: true,
|
|
itemHeight: 40,
|
|
visibleItems: 15,
|
|
pageSize: 20,
|
|
});
|
|
|
|
input.value = 'item';
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
await vi.runAllTimersAsync();
|
|
await Promise.resolve();
|
|
|
|
expect(autoComplete.items.length).toBeGreaterThan(0);
|
|
|
|
autoComplete.selectedIndex = 14;
|
|
|
|
const scrollTopBefore = autoComplete.scrollContainer?.scrollTop || 0;
|
|
|
|
const arrowDownEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true });
|
|
input.dispatchEvent(arrowDownEvent);
|
|
|
|
await vi.runAllTimersAsync();
|
|
await Promise.resolve();
|
|
|
|
expect(autoComplete.selectedIndex).toBe(15);
|
|
|
|
const selectedEl = autoComplete.contentContainer?.querySelector('.comfy-autocomplete-item-selected');
|
|
expect(selectedEl).toBeDefined();
|
|
expect(selectedEl?.dataset.index).toBe('15');
|
|
|
|
const scrollTopAfter = autoComplete.scrollContainer?.scrollTop || 0;
|
|
expect(scrollTopAfter).toBeGreaterThanOrEqual(scrollTopBefore);
|
|
});
|
|
|
|
it('replaces entire multi-word phrase when it matches selected tag (Danbooru convention)', async () => {
|
|
const mockTags = [
|
|
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
|
|
{ tag_name: 'looking_away', category: 0, post_count: 5678 },
|
|
];
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({ success: true, words: mockTags }),
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the side');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = 'looking to the side';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', {
|
|
debounceDelay: 0,
|
|
showPreview: false,
|
|
minChars: 1,
|
|
});
|
|
|
|
autoComplete.searchType = 'custom_words';
|
|
autoComplete.activeCommand = null;
|
|
autoComplete.items = mockTags;
|
|
autoComplete.selectedIndex = 0;
|
|
|
|
await autoComplete.insertSelection('looking_to_the_side');
|
|
|
|
expect(input.value).toBe('looking_to_the_side,');
|
|
expect(autoComplete.dropdown.style.display).toBe('none');
|
|
expect(input.focus).toHaveBeenCalled();
|
|
});
|
|
|
|
it('replaces only last token when typing partial match (e.g., "hello 1gi" -> "1girl")', async () => {
|
|
const mockTags = [
|
|
{ tag_name: '1girl', category: 4, post_count: 500000 },
|
|
{ tag_name: '1boy', category: 4, post_count: 300000 },
|
|
];
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({ success: true, words: mockTags }),
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('hello 1gi');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = 'hello 1gi';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', {
|
|
debounceDelay: 0,
|
|
showPreview: false,
|
|
minChars: 1,
|
|
});
|
|
|
|
autoComplete.searchType = 'custom_words';
|
|
autoComplete.activeCommand = null;
|
|
autoComplete.items = mockTags;
|
|
autoComplete.selectedIndex = 0;
|
|
autoComplete.currentSearchTerm = 'hello 1gi';
|
|
|
|
await autoComplete.insertSelection('1girl');
|
|
|
|
expect(input.value).toBe('hello 1girl,');
|
|
});
|
|
|
|
it('replaces entire phrase for underscore tag match (e.g., "blue hair" -> "blue_hair")', async () => {
|
|
const mockTags = [
|
|
{ tag_name: 'blue_hair', category: 0, post_count: 45000 },
|
|
{ tag_name: 'blue_eyes', category: 0, post_count: 80000 },
|
|
];
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({ success: true, words: mockTags }),
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('blue hair');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = 'blue hair';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', {
|
|
debounceDelay: 0,
|
|
showPreview: false,
|
|
minChars: 1,
|
|
});
|
|
|
|
autoComplete.searchType = 'custom_words';
|
|
autoComplete.activeCommand = null;
|
|
autoComplete.items = mockTags;
|
|
autoComplete.selectedIndex = 0;
|
|
autoComplete.currentSearchTerm = 'blue hair';
|
|
|
|
await autoComplete.insertSelection('blue_hair');
|
|
|
|
expect(input.value).toBe('blue_hair,');
|
|
});
|
|
|
|
it('handles multi-word phrase with preceding text correctly', async () => {
|
|
const mockTags = [
|
|
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
|
|
];
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({ success: true, words: mockTags }),
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('1girl, looking to the side');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = '1girl, looking to the side';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', {
|
|
debounceDelay: 0,
|
|
showPreview: false,
|
|
minChars: 1,
|
|
});
|
|
|
|
autoComplete.searchType = 'custom_words';
|
|
autoComplete.activeCommand = null;
|
|
autoComplete.items = mockTags;
|
|
autoComplete.selectedIndex = 0;
|
|
autoComplete.currentSearchTerm = 'looking to the side';
|
|
|
|
await autoComplete.insertSelection('looking_to_the_side');
|
|
|
|
expect(input.value).toBe('1girl, looking_to_the_side,');
|
|
});
|
|
|
|
it('replaces entire command and search term when using command mode with multi-word phrase', async () => {
|
|
const mockTags = [
|
|
{ tag_name: 'looking_to_the_side', category: 4, post_count: 1234 },
|
|
{ tag_name: 'looking_away', category: 4, post_count: 5678 },
|
|
];
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({ success: true, words: mockTags }),
|
|
});
|
|
|
|
// Simulate "/character looking to the side" input
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/character looking to the side');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = '/character looking to the side';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', {
|
|
debounceDelay: 0,
|
|
showPreview: false,
|
|
minChars: 1,
|
|
});
|
|
|
|
// Set up command mode state
|
|
autoComplete.searchType = 'custom_words';
|
|
autoComplete.activeCommand = { categories: [4, 11], label: 'Character' };
|
|
autoComplete.items = mockTags;
|
|
autoComplete.selectedIndex = 0;
|
|
autoComplete.currentSearchTerm = '/character looking to the side';
|
|
|
|
await autoComplete.insertSelection('looking_to_the_side');
|
|
|
|
// Command part should be replaced along with search term
|
|
expect(input.value).toBe('looking_to_the_side,');
|
|
});
|
|
|
|
it('replaces only last token when multi-word query does not exactly match selected tag', async () => {
|
|
const mockTags = [
|
|
{ tag_name: 'blue_hair', category: 0, post_count: 45000 },
|
|
{ tag_name: 'blue_eyes', category: 0, post_count: 80000 },
|
|
];
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({ success: true, words: mockTags }),
|
|
});
|
|
|
|
// User types "looking to the blue" but selects "blue_hair" (doesn't match entire phrase)
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the blue');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = 'looking to the blue';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', {
|
|
debounceDelay: 0,
|
|
showPreview: false,
|
|
minChars: 1,
|
|
});
|
|
|
|
autoComplete.searchType = 'custom_words';
|
|
autoComplete.activeCommand = null;
|
|
autoComplete.items = mockTags;
|
|
autoComplete.selectedIndex = 0;
|
|
autoComplete.currentSearchTerm = 'looking to the blue';
|
|
|
|
await autoComplete.insertSelection('blue_hair');
|
|
|
|
// Only "blue" should be replaced, not the entire phrase
|
|
expect(input.value).toBe('looking to the blue_hair,');
|
|
});
|
|
|
|
it('handles multiple consecutive spaces in multi-word phrase correctly', async () => {
|
|
const mockTags = [
|
|
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
|
|
];
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({ success: true, words: mockTags }),
|
|
});
|
|
|
|
// Input with multiple spaces between words
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the side');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = 'looking to the side';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', {
|
|
debounceDelay: 0,
|
|
showPreview: false,
|
|
minChars: 1,
|
|
});
|
|
|
|
autoComplete.searchType = 'custom_words';
|
|
autoComplete.activeCommand = null;
|
|
autoComplete.items = mockTags;
|
|
autoComplete.selectedIndex = 0;
|
|
autoComplete.currentSearchTerm = 'looking to the side';
|
|
|
|
await autoComplete.insertSelection('looking_to_the_side');
|
|
|
|
// Multiple spaces should be normalized to single underscores for matching
|
|
expect(input.value).toBe('looking_to_the_side,');
|
|
});
|
|
|
|
it('handles command mode with partial match replacing only last token', async () => {
|
|
const mockTags = [
|
|
{ tag_name: 'blue_hair', category: 0, post_count: 45000 },
|
|
];
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({ success: true, words: mockTags }),
|
|
});
|
|
|
|
// Command mode but selected tag doesn't match entire search phrase
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/general looking to the blue');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = '/general looking to the blue';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', {
|
|
debounceDelay: 0,
|
|
showPreview: false,
|
|
minChars: 1,
|
|
});
|
|
|
|
// Command mode with activeCommand
|
|
autoComplete.searchType = 'custom_words';
|
|
autoComplete.activeCommand = { categories: [0, 7], label: 'General' };
|
|
autoComplete.items = mockTags;
|
|
autoComplete.selectedIndex = 0;
|
|
autoComplete.currentSearchTerm = '/general looking to the blue';
|
|
|
|
await autoComplete.insertSelection('blue_hair');
|
|
|
|
// In command mode, the entire command + search term should be replaced
|
|
expect(input.value).toBe('blue_hair,');
|
|
});
|
|
|
|
it('replaces entire phrase when selected tag starts with underscore version of search term (prefix match)', async () => {
|
|
const mockTags = [
|
|
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
|
|
];
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({ success: true, words: mockTags }),
|
|
});
|
|
|
|
// User types partial phrase "looking to the" and selects "looking_to_the_side"
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = 'looking to the';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', {
|
|
debounceDelay: 0,
|
|
showPreview: false,
|
|
minChars: 1,
|
|
});
|
|
|
|
autoComplete.searchType = 'custom_words';
|
|
autoComplete.activeCommand = null;
|
|
autoComplete.items = mockTags;
|
|
autoComplete.selectedIndex = 0;
|
|
autoComplete.currentSearchTerm = 'looking to the';
|
|
|
|
await autoComplete.insertSelection('looking_to_the_side');
|
|
|
|
// Entire phrase should be replaced with selected tag (with underscores)
|
|
expect(input.value).toBe('looking_to_the_side,');
|
|
});
|
|
|
|
it('inserts tag with underscores regardless of space replacement setting', async () => {
|
|
const mockTags = [
|
|
{ tag_name: 'blue_hair', category: 0, post_count: 45000 },
|
|
];
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({ success: true, words: mockTags }),
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('blue');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = 'blue';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', {
|
|
debounceDelay: 0,
|
|
showPreview: false,
|
|
minChars: 1,
|
|
});
|
|
|
|
autoComplete.searchType = 'custom_words';
|
|
autoComplete.activeCommand = null;
|
|
autoComplete.items = mockTags;
|
|
autoComplete.selectedIndex = 0;
|
|
|
|
await autoComplete.insertSelection('blue_hair');
|
|
|
|
// Tag should be inserted with underscores, not spaces
|
|
expect(input.value).toBe('blue_hair,');
|
|
});
|
|
|
|
it('omits the trailing comma when the append comma setting is disabled', async () => {
|
|
settingGetMock.mockImplementation((key) => {
|
|
if (key === 'loramanager.autocomplete_append_comma') {
|
|
return false;
|
|
}
|
|
if (key === 'loramanager.prompt_tag_autocomplete') {
|
|
return true;
|
|
}
|
|
if (key === 'loramanager.tag_space_replacement') {
|
|
return false;
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
const mockTags = [
|
|
{ tag_name: 'blue_hair', category: 0, post_count: 45000 },
|
|
];
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('blue hair');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = 'blue hair';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', {
|
|
debounceDelay: 0,
|
|
showPreview: false,
|
|
minChars: 1,
|
|
});
|
|
|
|
autoComplete.searchType = 'custom_words';
|
|
autoComplete.activeCommand = null;
|
|
autoComplete.items = mockTags;
|
|
autoComplete.selectedIndex = 0;
|
|
autoComplete.currentSearchTerm = 'blue hair';
|
|
|
|
await autoComplete.insertSelection('blue_hair');
|
|
|
|
expect(input.value).toBe('blue_hair ');
|
|
});
|
|
|
|
it('uses persisted autocomplete metadata as the next search start when comma append is disabled', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
settingGetMock.mockImplementation((key) => {
|
|
if (key === 'loramanager.autocomplete_append_comma') {
|
|
return false;
|
|
}
|
|
if (key === 'loramanager.prompt_tag_autocomplete') {
|
|
return true;
|
|
}
|
|
if (key === 'loramanager.tag_space_replacement') {
|
|
return false;
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({ success: true, words: [{ tag_name: 'cat_ears', category: 0, post_count: 1234 }] }),
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('1girl cat');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = '1girl cat';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
input._autocompleteMetadataWidget = {
|
|
value: {
|
|
version: 1,
|
|
textWidgetName: 'text',
|
|
lastAccepted: {
|
|
start: 0,
|
|
end: 6,
|
|
insertedText: '1girl ',
|
|
textSnapshot: '1girl ',
|
|
},
|
|
},
|
|
};
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', {
|
|
debounceDelay: 0,
|
|
showPreview: false,
|
|
minChars: 1,
|
|
});
|
|
|
|
expect(autoComplete.getSearchTerm(input.value)).toBe('cat');
|
|
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
await vi.runAllTimersAsync();
|
|
await Promise.resolve();
|
|
|
|
expect(fetchApiMock).toHaveBeenCalledWith('/lm/custom-words/search?enriched=true&search=cat&limit=100');
|
|
});
|
|
|
|
it('searches wildcard keys when using the /wildcard command', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({
|
|
success: true,
|
|
words: ['animals/cat'],
|
|
meta: {
|
|
has_wildcards: true,
|
|
wildcards_dir: '/tmp/settings/wildcards',
|
|
supported_formats: ['.txt', '.yaml', '.yml', '.json'],
|
|
},
|
|
}),
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard cat');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = '/wildcard cat';
|
|
input.selectionStart = input.value.length;
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', {
|
|
debounceDelay: 0,
|
|
showPreview: false,
|
|
minChars: 1,
|
|
});
|
|
|
|
fetchApiMock.mockClear();
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
await vi.runAllTimersAsync();
|
|
await Promise.resolve();
|
|
|
|
expect(fetchApiMock).toHaveBeenCalledWith('/lm/wildcards/search?search=cat&limit=100');
|
|
expect(autoComplete.searchType).toBe('wildcards');
|
|
expect(autoComplete.items).toEqual(['animals/cat']);
|
|
});
|
|
|
|
it('shows wildcard onboarding when /wildcard is used before any files exist', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({
|
|
success: true,
|
|
words: [],
|
|
meta: {
|
|
has_wildcards: false,
|
|
wildcards_dir: '/tmp/settings/wildcards',
|
|
supported_formats: ['.txt', '.yaml', '.yml', '.json'],
|
|
},
|
|
}),
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard cat');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = '/wildcard cat';
|
|
input.selectionStart = input.value.length;
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', {
|
|
debounceDelay: 0,
|
|
showPreview: false,
|
|
minChars: 1,
|
|
});
|
|
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
await vi.runAllTimersAsync();
|
|
await Promise.resolve();
|
|
|
|
expect(autoComplete.isVisible).toBe(true);
|
|
expect(autoComplete.items).toHaveLength(1);
|
|
expect(autoComplete.items[0].type).toBe('wildcard_empty_state');
|
|
expect(autoComplete.dropdown.textContent).toContain('No wildcards found yet');
|
|
expect(autoComplete.dropdown.textContent).toContain('/tmp/settings/wildcards');
|
|
expect(autoComplete.dropdown.textContent).toContain('.txt, .yaml, .yml, .json');
|
|
});
|
|
|
|
it('shows wildcard onboarding when only the /wildcard command is entered', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({
|
|
success: true,
|
|
words: [],
|
|
meta: {
|
|
has_wildcards: false,
|
|
wildcards_dir: '/tmp/settings/wildcards',
|
|
supported_formats: ['.txt', '.yaml', '.yml', '.json'],
|
|
},
|
|
}),
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard ');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = '/wildcard ';
|
|
input.selectionStart = input.value.length;
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', {
|
|
debounceDelay: 0,
|
|
showPreview: false,
|
|
minChars: 1,
|
|
});
|
|
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
await vi.runAllTimersAsync();
|
|
await Promise.resolve();
|
|
|
|
expect(fetchApiMock).toHaveBeenCalledWith('/lm/wildcards/search?search=&limit=100');
|
|
expect(autoComplete.isVisible).toBe(true);
|
|
expect(autoComplete.items).toHaveLength(1);
|
|
expect(autoComplete.items[0].type).toBe('wildcard_empty_state');
|
|
expect(autoComplete.dropdown.textContent).toContain('No wildcards found yet');
|
|
});
|
|
|
|
it('shows a lightweight no-match state when wildcard files exist but search misses', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({
|
|
success: true,
|
|
words: [],
|
|
meta: {
|
|
has_wildcards: true,
|
|
wildcards_dir: '/tmp/settings/wildcards',
|
|
supported_formats: ['.txt', '.yaml', '.yml', '.json'],
|
|
},
|
|
}),
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard dragon');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = '/wildcard dragon';
|
|
input.selectionStart = input.value.length;
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', {
|
|
debounceDelay: 0,
|
|
showPreview: false,
|
|
minChars: 1,
|
|
});
|
|
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
await vi.runAllTimersAsync();
|
|
await Promise.resolve();
|
|
|
|
expect(autoComplete.items).toHaveLength(1);
|
|
expect(autoComplete.items[0].type).toBe('wildcard_no_matches');
|
|
expect(autoComplete.dropdown.textContent).toContain('No wildcard matches');
|
|
expect(autoComplete.dropdown.textContent).not.toContain('Open wildcards folder');
|
|
});
|
|
|
|
it('inserts wildcard references when accepting a /wildcard result', async () => {
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard animals/cat');
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = '/wildcard animals/cat';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', {
|
|
debounceDelay: 0,
|
|
showPreview: false,
|
|
minChars: 1,
|
|
});
|
|
|
|
autoComplete.searchType = 'wildcards';
|
|
autoComplete.activeCommand = { type: 'wildcard', label: 'Wildcards' };
|
|
autoComplete.items = ['animals/cat'];
|
|
autoComplete.selectedIndex = 0;
|
|
|
|
await autoComplete.insertSelection('animals/cat');
|
|
|
|
expect(input.value).toBe('__animals/cat__,');
|
|
expect(input.focus).toHaveBeenCalled();
|
|
expect(input.setSelectionRange).toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not reopen autocomplete on blur after inserting a wildcard literal', async () => {
|
|
const input = document.createElement('textarea');
|
|
input.value = '__flower__,';
|
|
input.selectionStart = input.value.length;
|
|
document.body.append(input);
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('__flower__,');
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', {
|
|
debounceDelay: 0,
|
|
showPreview: false,
|
|
minChars: 1,
|
|
});
|
|
|
|
const hideSpy = vi.spyOn(autoComplete, 'hide');
|
|
input.dispatchEvent(new Event('blur', { bubbles: true }));
|
|
|
|
expect(fetchApiMock).not.toHaveBeenCalled();
|
|
expect(hideSpy).toHaveBeenCalled();
|
|
expect(autoComplete.isVisible).toBe(false);
|
|
});
|
|
|
|
it('treats a command after a wildcard literal as the active token', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({
|
|
success: true,
|
|
words: [{ tag_name: 'flower_field', category: 4, post_count: 1234 }],
|
|
}),
|
|
});
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = '__flower__ /character f';
|
|
input.selectionStart = input.value.length;
|
|
document.body.append(input);
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('__flower__ /character f');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', {
|
|
debounceDelay: 0,
|
|
showPreview: false,
|
|
minChars: 1,
|
|
});
|
|
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
await vi.runAllTimersAsync();
|
|
await Promise.resolve();
|
|
|
|
expect(autoComplete.getSearchTerm(input.value)).toBe('/character f');
|
|
});
|
|
|
|
it('invalidates stale autocomplete metadata and falls back to delimiter-based matching', async () => {
|
|
settingGetMock.mockImplementation((key) => {
|
|
if (key === 'loramanager.autocomplete_append_comma') {
|
|
return false;
|
|
}
|
|
if (key === 'loramanager.prompt_tag_autocomplete') {
|
|
return true;
|
|
}
|
|
if (key === 'loramanager.tag_space_replacement') {
|
|
return false;
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('1boy cat');
|
|
|
|
const metadataWidget = {
|
|
value: {
|
|
version: 1,
|
|
textWidgetName: 'text',
|
|
lastAccepted: {
|
|
start: 0,
|
|
end: 6,
|
|
insertedText: '1girl ',
|
|
textSnapshot: '1girl ',
|
|
},
|
|
},
|
|
};
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = '1boy cat';
|
|
input.selectionStart = input.value.length;
|
|
input._autocompleteMetadataWidget = metadataWidget;
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', {
|
|
debounceDelay: 0,
|
|
showPreview: false,
|
|
minChars: 1,
|
|
});
|
|
|
|
expect(autoComplete.getSearchTerm(input.value)).toBe('1boy cat');
|
|
expect(metadataWidget.value.lastAccepted).toBeUndefined();
|
|
});
|
|
|
|
it('does not duplicate the first character when accepting a suggestion after a trailing space', async () => {
|
|
settingGetMock.mockImplementation((key) => {
|
|
if (key === 'loramanager.autocomplete_append_comma') {
|
|
return false;
|
|
}
|
|
if (key === 'loramanager.prompt_tag_autocomplete') {
|
|
return true;
|
|
}
|
|
if (key === 'loramanager.tag_space_replacement') {
|
|
return false;
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
const mockTags = [
|
|
{ tag_name: '1girl', category: 4, post_count: 500000 },
|
|
];
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('1girl ');
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = '1girl ';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', {
|
|
debounceDelay: 0,
|
|
showPreview: false,
|
|
minChars: 1,
|
|
});
|
|
|
|
autoComplete.searchType = 'custom_words';
|
|
autoComplete.activeCommand = null;
|
|
autoComplete.items = mockTags;
|
|
autoComplete.selectedIndex = 0;
|
|
|
|
await autoComplete.insertSelection('1girl');
|
|
|
|
expect(input.value).toBe('1girl ');
|
|
});
|
|
|
|
it('treats a newline as a hard boundary after dismissing autocomplete', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({ success: true, words: [{ tag_name: '1girl', category: 4, post_count: 500000 }] }),
|
|
});
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = '1gi\n';
|
|
input.selectionStart = input.value.length;
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', {
|
|
debounceDelay: 0,
|
|
showPreview: false,
|
|
minChars: 1,
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('1gi');
|
|
autoComplete.handleInput('1gi');
|
|
await vi.runAllTimersAsync();
|
|
await Promise.resolve();
|
|
expect(fetchApiMock).toHaveBeenCalled();
|
|
|
|
fetchApiMock.mockClear();
|
|
autoComplete.hide();
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('1gi\n');
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
await vi.runAllTimersAsync();
|
|
await Promise.resolve();
|
|
|
|
expect(autoComplete.getSearchTerm(input.value)).toBe('');
|
|
expect(fetchApiMock).not.toHaveBeenCalled();
|
|
expect(autoComplete.isVisible).toBe(false);
|
|
});
|
|
|
|
it('omits the trailing comma for LoRA insertions when the setting is disabled', async () => {
|
|
settingGetMock.mockImplementation((key) => {
|
|
if (key === 'loramanager.autocomplete_append_comma') {
|
|
return false;
|
|
}
|
|
if (key === 'loramanager.prompt_tag_autocomplete') {
|
|
return true;
|
|
}
|
|
if (key === 'loramanager.tag_space_replacement') {
|
|
return false;
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
fetchApiMock.mockImplementation((url) => {
|
|
if (url.includes('usage-tips-by-path')) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
success: true,
|
|
usage_tips: JSON.stringify({ strength: '1.2' }),
|
|
}),
|
|
});
|
|
}
|
|
|
|
return Promise.resolve({
|
|
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
|
|
});
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('alpha, example');
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = 'alpha, example';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'loras', { debounceDelay: 0, showPreview: false });
|
|
|
|
await autoComplete.insertSelection('models/example.safetensors');
|
|
|
|
expect(input.value).toContain('<lora:example:1.2>');
|
|
expect(input.value).not.toContain('<lora:example:1.2>,');
|
|
});
|
|
|
|
it('replaces entire phrase when selected tag ends with underscore version of search term (suffix match)', async () => {
|
|
const mockTags = [
|
|
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
|
|
];
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({ success: true, words: mockTags }),
|
|
});
|
|
|
|
// User types suffix "to the side" and selects "looking_to_the_side"
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('to the side');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = 'to the side';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input,'prompt', {
|
|
debounceDelay: 0,
|
|
showPreview: false,
|
|
minChars: 1,
|
|
});
|
|
|
|
autoComplete.searchType = 'custom_words';
|
|
autoComplete.activeCommand = null;
|
|
autoComplete.items = mockTags;
|
|
autoComplete.selectedIndex = 0;
|
|
autoComplete.currentSearchTerm = 'to the side';
|
|
|
|
await autoComplete.insertSelection('looking_to_the_side');
|
|
|
|
// Entire phrase should be replaced with selected tag
|
|
expect(input.value).toBe('looking_to_the_side,');
|
|
});
|
|
|
|
it('shows /af command for loras when active-filters autocomplete is off (default)', async () => {
|
|
const input = document.createElement('textarea');
|
|
input.value = '/';
|
|
input.selectionStart = input.value.length;
|
|
document.body.append(input);
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
|
|
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
const commandNames = autoComplete.items.map((item) => item.command);
|
|
expect(commandNames).toContain('/af');
|
|
expect(commandNames).not.toContain('/noaf');
|
|
expect(commandNames).toContain('/activefilters');
|
|
expect(commandNames).not.toContain('/noactivefilters');
|
|
});
|
|
|
|
it('does not trigger preview for command items when selecting the loras command list', async () => {
|
|
// Regression: with showPreview enabled (the default for loras widgets), the
|
|
// auto-selected first command item was passed to showPreviewForItem() as a
|
|
// relative path, crashing on relativePath.split.
|
|
const input = document.createElement('textarea');
|
|
input.value = '/';
|
|
input.selectionStart = input.value.length;
|
|
document.body.append(input);
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input, 'loras', { showPreview: true, minChars: 1 });
|
|
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
// Allow the async preview tooltip import to resolve
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
const commandNames = autoComplete.items.map((item) => item.command);
|
|
expect(commandNames).toContain('/af');
|
|
expect(previewTooltipMock.show).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows /noaf command for loras when active-filters autocomplete is on', async () => {
|
|
settingGetMock.mockImplementation((key) => {
|
|
if (key === 'loramanager.lora_active_filters_autocomplete') {
|
|
return true;
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
const input = document.createElement('textarea');
|
|
input.value = '/';
|
|
input.selectionStart = input.value.length;
|
|
document.body.append(input);
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
|
|
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
const commandNames = autoComplete.items.map((item) => item.command);
|
|
expect(commandNames).toContain('/noaf');
|
|
expect(commandNames).not.toContain('/af');
|
|
expect(commandNames).toContain('/noactivefilters');
|
|
expect(commandNames).not.toContain('/activefilters');
|
|
});
|
|
|
|
it('toggles the active-filters setting when /activefilters alias is used', async () => {
|
|
const input = document.createElement('textarea');
|
|
input.value = '/activefilters';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/activefilters');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
|
|
|
|
const commandResult = autoComplete._parseCommandInput('/activefilters');
|
|
expect(commandResult.command).toBeDefined();
|
|
expect(commandResult.command.type).toBe('toggle_setting');
|
|
expect(commandResult.command.value).toBe(true);
|
|
|
|
await autoComplete._handleToggleSettingCommand(commandResult.command);
|
|
|
|
expect(settingSetMock).toHaveBeenCalledWith('loramanager.lora_active_filters_autocomplete', true);
|
|
});
|
|
|
|
it('toggles the active-filters setting when /af is accepted', async () => {
|
|
const input = document.createElement('textarea');
|
|
input.value = '/';
|
|
input.selectionStart = input.value.length;
|
|
input.focus = vi.fn();
|
|
input.setSelectionRange = vi.fn();
|
|
document.body.append(input);
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
|
|
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
const afItem = autoComplete.items.find((item) => item.command === '/af');
|
|
expect(afItem).toBeDefined();
|
|
|
|
// Simulate the input being cleared after the command is accepted so the
|
|
// cleared-token input event does not re-trigger command parsing.
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('');
|
|
await autoComplete._handleToggleSettingCommand(afItem);
|
|
|
|
expect(settingSetMock).toHaveBeenCalledWith('loramanager.lora_active_filters_autocomplete', true);
|
|
});
|
|
|
|
it('appends active filter params to loras autocomplete requests when enabled', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
settingGetMock.mockImplementation((key) => {
|
|
if (key === 'loramanager.lora_active_filters_autocomplete') {
|
|
return true;
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
localStorage.setItem('lora_manager_loras_filters', JSON.stringify({
|
|
baseModel: ['SD 1.5'],
|
|
tags: { anime: 'include', nsfw: 'exclude', __no_tags__: 'exclude' },
|
|
autoTags: { I2V: 'include' },
|
|
modelTypes: ['standard'],
|
|
tagLogic: 'all',
|
|
license: { noCredit: 'include', allowSelling: 'exclude' },
|
|
}));
|
|
localStorage.setItem('lora_manager_loras_activeFolder', 'MyLoras');
|
|
localStorage.setItem('lora_manager_loras_recursiveSearch', 'true');
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const input = document.createElement('textarea');
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });
|
|
|
|
input.value = 'example';
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
await vi.runAllTimersAsync();
|
|
await Promise.resolve();
|
|
|
|
const calledUrl = fetchApiMock.mock.calls[0][0];
|
|
expect(calledUrl).toContain('/lm/loras/relative-paths?search=example&limit=100');
|
|
expect(calledUrl).toContain('folder=MyLoras');
|
|
expect(calledUrl).toContain('recursive=true');
|
|
expect(calledUrl).toContain('tag_include=anime');
|
|
expect(calledUrl).toContain('tag_exclude=nsfw');
|
|
expect(calledUrl).toContain('tag_exclude=__no_tags__');
|
|
expect(calledUrl).toContain('auto_tag_include=I2V');
|
|
expect(calledUrl).toContain('tag_logic=all');
|
|
expect(calledUrl).toContain('credit_required=false');
|
|
expect(calledUrl).toContain('allow_selling_generated_content=false');
|
|
const parsed = new URL(calledUrl, 'https://example.com');
|
|
expect(parsed.searchParams.get('base_model')).toBe('SD 1.5');
|
|
expect(parsed.searchParams.get('model_type')).toBe('standard');
|
|
});
|
|
|
|
it('keeps the default loras autocomplete URL when active-filters mode is off', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const input = document.createElement('textarea');
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });
|
|
|
|
input.value = 'example';
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
await vi.runAllTimersAsync();
|
|
await Promise.resolve();
|
|
|
|
expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/relative-paths?search=example&limit=100');
|
|
});
|
|
|
|
it('sends the filter-pipeline signal even when no filters are stored', async () => {
|
|
// Regression: with filter mode on but no folder/filters stored, the request
|
|
// carried no params, so the backend skipped the filter pipeline and global
|
|
// settings like show_only_sfw diverged from the list endpoint.
|
|
vi.useFakeTimers();
|
|
|
|
settingGetMock.mockImplementation((key) => {
|
|
if (key === 'loramanager.lora_active_filters_autocomplete') {
|
|
return true;
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
localStorage.removeItem('lora_manager_loras_filters');
|
|
localStorage.removeItem('lora_manager_loras_activeFolder');
|
|
localStorage.removeItem('lora_manager_loras_recursiveSearch');
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const input = document.createElement('textarea');
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });
|
|
|
|
input.value = 'example';
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
await vi.runAllTimersAsync();
|
|
await Promise.resolve();
|
|
|
|
const calledUrl = fetchApiMock.mock.calls[0][0];
|
|
expect(calledUrl).toContain('recursive=true');
|
|
});
|
|
|
|
it('omits folder param when active folder is root and recursion is enabled', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
settingGetMock.mockImplementation((key) => {
|
|
if (key === 'loramanager.lora_active_filters_autocomplete') {
|
|
return true;
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
localStorage.setItem('lora_manager_loras_filters', JSON.stringify({
|
|
baseModel: ['SD 1.5'],
|
|
tags: { anime: 'include' },
|
|
}));
|
|
localStorage.setItem('lora_manager_loras_activeFolder', '');
|
|
localStorage.removeItem('lora_manager_loras_recursiveSearch');
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const input = document.createElement('textarea');
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });
|
|
|
|
input.value = 'example';
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
await vi.runAllTimersAsync();
|
|
await Promise.resolve();
|
|
|
|
const calledUrl = fetchApiMock.mock.calls[0][0];
|
|
expect(calledUrl).not.toContain('folder=');
|
|
expect(calledUrl).toContain('recursive=true');
|
|
});
|
|
|
|
it('sends an empty folder param for root with recursion disabled, mirroring the page list', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
settingGetMock.mockImplementation((key) => {
|
|
if (key === 'loramanager.lora_active_filters_autocomplete') {
|
|
return true;
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
localStorage.setItem('lora_manager_loras_filters', JSON.stringify({
|
|
baseModel: ['SD 1.5'],
|
|
tags: { anime: 'include' },
|
|
}));
|
|
localStorage.setItem('lora_manager_loras_activeFolder', '');
|
|
localStorage.setItem('lora_manager_loras_recursiveSearch', 'false');
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const input = document.createElement('textarea');
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });
|
|
|
|
input.value = 'example';
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
await vi.runAllTimersAsync();
|
|
await Promise.resolve();
|
|
|
|
const calledUrl = fetchApiMock.mock.calls[0][0];
|
|
expect(calledUrl).toContain('folder=');
|
|
expect(calledUrl).toContain('recursive=false');
|
|
const parsed = new URL(calledUrl, 'https://example.com');
|
|
expect(parsed.searchParams.get('folder')).toBe('');
|
|
});
|
|
|
|
it('applies the active folder even when no filter-panel filters are set', async () => {
|
|
// Regression: folder was skipped when lora_manager_loras_filters was
|
|
// missing because the filters key gate returned early.
|
|
vi.useFakeTimers();
|
|
|
|
settingGetMock.mockImplementation((key) => {
|
|
if (key === 'loramanager.lora_active_filters_autocomplete') {
|
|
return true;
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
localStorage.removeItem('lora_manager_loras_filters');
|
|
localStorage.setItem('lora_manager_loras_activeFolder', 'Flux.1 D/style');
|
|
|
|
fetchApiMock.mockResolvedValue({
|
|
json: () => Promise.resolve({ success: true, relative_paths: ['Flux.1 D/style/3D_Fairytales.safetensors'] }),
|
|
});
|
|
|
|
caretHelperInstance.getBeforeCursor.mockReturnValue('3D');
|
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
|
|
|
const input = document.createElement('textarea');
|
|
document.body.append(input);
|
|
|
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });
|
|
|
|
input.value = '3D';
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
await vi.runAllTimersAsync();
|
|
await Promise.resolve();
|
|
|
|
const calledUrl = fetchApiMock.mock.calls[0][0];
|
|
expect(calledUrl).toContain('folder=Flux.1+D%2Fstyle');
|
|
expect(calledUrl).toContain('recursive=true');
|
|
});
|
|
});
|