mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
cf64e5baa8
The indicator chip added for /activefilters discoverability was broken by design of its import path: AutocompleteTextWidget.vue imported web/comfyui/settings.js into the vue-widgets bundle, and settings.js's "../../scripts/app.js" import resolved at build time to the repo-root test shim (scripts/app.js, an in-memory settings store). The chip therefore read and wrote an orphaned in-memory Map: clicking it flipped only its own visual state and never touched the real ComfyUI setting that autocomplete.js consults (use_active_filters query param). Beyond the defect, a persistent per-node control for a global persisted setting misleads users and needs cross-instance sync machinery, which the footer hint, slash commands, right-click menu entry and settings dialog already cover. - AutocompleteTextWidget.vue: remove the chip button, its state/handlers, the settings.js import (the shim-inlining pathway) and all chip styles - AutocompleteTextWidget.test.ts: drop the chip indicator describe block and the settings.js module mock; beforeEach import no longer needed - settings.js: drop the lora-manager:setting-toggled window broadcast and its export — the chip was its only consumer, so every setLoraManagerSettingValue write no longer dispatches a dead event - autocomplete.activeFilters.test.js: drop the broadcast assertion test - loraLoader.activeFiltersMenu.test.js: drop SETTING_TOGGLED_EVENT_NAME from the settings.js mock Discoverability of /activefilters // /noactivefilters is unchanged: command-list footer, first-run hint, node context menu, settings dialog.
221 lines
8.3 KiB
TypeScript
221 lines
8.3 KiB
TypeScript
/**
|
|
* Tests for AutocompleteTextWidget — clear button behavior.
|
|
*
|
|
* The clear button must clear the textarea through a trusted editing command
|
|
* (execCommand) so the browser records an undo entry and Ctrl+Z (with focus
|
|
* in the textarea) can restore the cleared content. jsdom's execCommand is a
|
|
* no-op that returns false, so the fallback manual-clear path is exercised by
|
|
* default; the execCommand path is covered by emulating the browser edit.
|
|
*/
|
|
|
|
import { nextTick } from 'vue'
|
|
import { shallowMount } from '@vue/test-utils'
|
|
import { describe, expect, it, vi, afterEach } from 'vitest'
|
|
import AutocompleteTextWidget from '@/components/AutocompleteTextWidget.vue'
|
|
|
|
function createMockWidget() {
|
|
return {
|
|
callback: vi.fn(),
|
|
onSetValue: undefined,
|
|
inputEl: undefined,
|
|
metadataWidget: undefined,
|
|
name: 'text',
|
|
}
|
|
}
|
|
|
|
function mountWidget() {
|
|
const widget = createMockWidget()
|
|
const node = { id: 1 }
|
|
const wrapper = shallowMount(AutocompleteTextWidget, {
|
|
props: { widget, node, modelType: 'prompt' },
|
|
// Attach to the document so jsdom implements real focus behavior
|
|
attachTo: document.body,
|
|
})
|
|
return { wrapper, widget }
|
|
}
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = ''
|
|
delete (document as unknown as { execCommand?: unknown }).execCommand
|
|
})
|
|
|
|
describe('AutocompleteTextWidget clear button', () => {
|
|
it('is hidden when the textarea is empty and appears once text is entered', async () => {
|
|
const { wrapper } = mountWidget()
|
|
|
|
expect(wrapper.find('.clear-button').exists()).toBe(false)
|
|
|
|
await wrapper.find('textarea').setValue('hello <lora:foo:1>')
|
|
|
|
expect(wrapper.find('.clear-button').exists()).toBe(true)
|
|
})
|
|
|
|
it('clears the textarea via the fallback path when execCommand is unavailable', async () => {
|
|
const { wrapper, widget } = mountWidget()
|
|
const textarea = wrapper.find('textarea')
|
|
|
|
await textarea.setValue('hello <lora:foo:1>')
|
|
expect(widget.callback).toHaveBeenLastCalledWith('hello <lora:foo:1>')
|
|
|
|
// jsdom does not define document.execCommand at all, so the availability
|
|
// guard fails and the fallback manual clear runs: value reset + synthetic
|
|
// input event.
|
|
await wrapper.find('.clear-button').trigger('click')
|
|
|
|
expect((textarea.element as HTMLTextAreaElement).value).toBe('')
|
|
expect(wrapper.find('.clear-button').exists()).toBe(false)
|
|
expect(widget.callback).toHaveBeenLastCalledWith('')
|
|
// Focus returns to the textarea so Ctrl+Z can trigger native undo
|
|
expect(document.activeElement).toBe(textarea.element)
|
|
})
|
|
|
|
it('clears through a trusted execCommand edit so the browser records an undo entry', async () => {
|
|
const { wrapper, widget } = mountWidget()
|
|
const textarea = wrapper.find('textarea')
|
|
|
|
await textarea.setValue('hello world')
|
|
widget.callback.mockClear()
|
|
|
|
// Emulate Chromium: replace the selection with the given text, then fire
|
|
// a trusted input event that Vue and the autocomplete listeners observe.
|
|
// jsdom has no document.execCommand, so define it for this test.
|
|
const execMock = vi.fn((_cmd: string, _showUI: boolean, value: string) => {
|
|
const ta = document.activeElement as HTMLTextAreaElement | null
|
|
if (!ta || ta.tagName !== 'TEXTAREA') return false
|
|
ta.value = String(value ?? '')
|
|
ta.dispatchEvent(new Event('input', { bubbles: true }))
|
|
return true
|
|
})
|
|
Object.defineProperty(document, 'execCommand', { configurable: true, value: execMock })
|
|
|
|
await wrapper.find('.clear-button').trigger('click')
|
|
|
|
expect(execMock).toHaveBeenCalledWith('insertText', false, '')
|
|
expect((textarea.element as HTMLTextAreaElement).value).toBe('')
|
|
// Callback is driven by the trusted input event (exactly once, no double call)
|
|
expect(widget.callback).toHaveBeenCalledTimes(1)
|
|
expect(widget.callback).toHaveBeenCalledWith('')
|
|
expect(wrapper.find('.clear-button').exists()).toBe(false)
|
|
})
|
|
|
|
it('collapses the selection when Ctrl+Z restores the cleared text', async () => {
|
|
const { wrapper, widget } = mountWidget()
|
|
const textarea = wrapper.find('textarea')
|
|
const ta = textarea.element as HTMLTextAreaElement
|
|
|
|
await textarea.setValue('hello world')
|
|
widget.callback.mockClear()
|
|
|
|
// Clear via the trusted edit path (emulated Chromium)
|
|
Object.defineProperty(document, 'execCommand', {
|
|
configurable: true,
|
|
value: vi.fn(() => {
|
|
ta.value = ''
|
|
ta.dispatchEvent(new Event('input', { bubbles: true }))
|
|
return true
|
|
}),
|
|
})
|
|
await wrapper.find('.clear-button').trigger('click')
|
|
|
|
// Simulate the browser's undo: restore the text and the captured
|
|
// full-text selection, then fire the historyUndo input event
|
|
ta.value = 'hello world'
|
|
ta.setSelectionRange(0, ta.value.length)
|
|
ta.dispatchEvent(
|
|
Object.assign(new Event('input', { bubbles: true }), { inputType: 'historyUndo' })
|
|
)
|
|
await nextTick()
|
|
|
|
expect(ta.value).toBe('hello world')
|
|
// The restored text must not remain selected — caret collapsed to the end
|
|
expect(ta.selectionStart).toBe(ta.value.length)
|
|
expect(ta.selectionEnd).toBe(ta.value.length)
|
|
expect(wrapper.find('.clear-button').exists()).toBe(true)
|
|
expect(widget.callback).toHaveBeenLastCalledWith('hello world')
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Tests for the vertical-scrollbar inset.
|
|
*
|
|
* When the textarea content overflows and a classic (non-overlay) scrollbar
|
|
* is shown, the absolutely-positioned corner clear (x) button would sit on
|
|
* top of the scrollbar. The component measures the scrollbar gutter and
|
|
* exposes it as the --lm-vscrollbar-width CSS var on .input-wrapper so the
|
|
* button shifts left of the scrollbar. jsdom does no layout, so overflow is
|
|
* simulated by overriding the scroll/dimension props.
|
|
*/
|
|
describe('AutocompleteTextWidget vertical scrollbar inset', () => {
|
|
function overrideTextareaMetrics(
|
|
ta: HTMLTextAreaElement,
|
|
metrics: { scrollHeight: number; clientHeight: number; offsetWidth: number; clientWidth: number }
|
|
) {
|
|
Object.defineProperty(ta, 'scrollHeight', { configurable: true, value: metrics.scrollHeight })
|
|
Object.defineProperty(ta, 'clientHeight', { configurable: true, value: metrics.clientHeight })
|
|
Object.defineProperty(ta, 'offsetWidth', { configurable: true, value: metrics.offsetWidth })
|
|
Object.defineProperty(ta, 'clientWidth', { configurable: true, value: metrics.clientWidth })
|
|
}
|
|
|
|
it('exposes the scrollbar width as a CSS var when the content overflows', async () => {
|
|
const { wrapper } = mountWidget()
|
|
const textarea = wrapper.find('textarea').element as HTMLTextAreaElement
|
|
|
|
overrideTextareaMetrics(textarea, {
|
|
scrollHeight: 200,
|
|
clientHeight: 100,
|
|
offsetWidth: 320,
|
|
clientWidth: 305, // 15px scrollbar gutter
|
|
})
|
|
textarea.dispatchEvent(new Event('input'))
|
|
await nextTick()
|
|
|
|
const wrapperEl = wrapper.find('.input-wrapper').element as HTMLElement
|
|
expect(wrapperEl.style.getPropertyValue('--lm-vscrollbar-width')).toBe('15px')
|
|
})
|
|
|
|
it('keeps the CSS var at 0px when there is no vertical scrollbar', async () => {
|
|
const { wrapper } = mountWidget()
|
|
const textarea = wrapper.find('textarea').element as HTMLTextAreaElement
|
|
|
|
overrideTextareaMetrics(textarea, {
|
|
scrollHeight: 100,
|
|
clientHeight: 100,
|
|
offsetWidth: 320,
|
|
clientWidth: 320,
|
|
})
|
|
textarea.dispatchEvent(new Event('input'))
|
|
await nextTick()
|
|
|
|
const wrapperEl = wrapper.find('.input-wrapper').element as HTMLElement
|
|
expect(wrapperEl.style.getPropertyValue('--lm-vscrollbar-width')).toBe('0px')
|
|
})
|
|
|
|
it('clears the inset once overflowing content is removed', async () => {
|
|
const { wrapper } = mountWidget()
|
|
const textarea = wrapper.find('textarea').element as HTMLTextAreaElement
|
|
|
|
overrideTextareaMetrics(textarea, {
|
|
scrollHeight: 200,
|
|
clientHeight: 100,
|
|
offsetWidth: 320,
|
|
clientWidth: 305,
|
|
})
|
|
textarea.dispatchEvent(new Event('input'))
|
|
await nextTick()
|
|
const wrapperEl = wrapper.find('.input-wrapper').element as HTMLElement
|
|
expect(wrapperEl.style.getPropertyValue('--lm-vscrollbar-width')).toBe('15px')
|
|
|
|
// Content now fits: no scrollbar → inset cleared
|
|
overrideTextareaMetrics(textarea, {
|
|
scrollHeight: 100,
|
|
clientHeight: 100,
|
|
offsetWidth: 320,
|
|
clientWidth: 320,
|
|
})
|
|
textarea.dispatchEvent(new Event('input'))
|
|
await nextTick()
|
|
expect(wrapperEl.style.getPropertyValue('--lm-vscrollbar-width')).toBe('0px')
|
|
})
|
|
|
|
})
|