mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-16 10:43:21 -03:00
2036 lines
72 KiB
JavaScript
2036 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 /activefilters 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('/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('/activefilters');
|
|
expect(previewTooltipMock.show).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows /noactivefilters 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('/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 /activefilters 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 === '/activefilters');
|
|
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');
|
|
});
|
|
});
|