mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-06 22:10:14 -03:00
feat(widgets): add Description tab to LoraInfoWidget with dual-mode rendering support
- Add Notes/Description tab switching with tab state persistence in widget value - Lazy-load model description and version description from /lm/loras/metadata - Render CivitAI HTML descriptions inline via v-html - Auto-fetch description when LoRA selection changes while on Description tab - Fix Vue mode height containment via contain:layout size (lm-vue-node class) - Fix scroll wheel isolation: widget scroll vs canvas zoom in both render modes - Add docs/comfyui-dual-mode-widgets.md with widget rendering patterns
This commit is contained in:
417
vue-widgets/tests/components/LoraInfoWidget.test.ts
Normal file
417
vue-widgets/tests/components/LoraInfoWidget.test.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* Tests for LoraInfoWidget — tab switching, lazy description loading,
|
||||
* state serialization roundtrip, and activeTab persistence.
|
||||
*/
|
||||
|
||||
import { nextTick } from 'vue'
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import LoraInfoWidget from '@/components/LoraInfoWidget.vue'
|
||||
import { setupFetchMock, resetFetchMock } from '../setup'
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function createMockFetchApi(overrides: {
|
||||
response?: unknown
|
||||
ok?: boolean
|
||||
error?: string
|
||||
} = {}) {
|
||||
const { response = { success: true, metadata: {} }, ok = true } = overrides
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok,
|
||||
json: () => Promise.resolve(response),
|
||||
})
|
||||
}
|
||||
|
||||
function createMockToast() {
|
||||
return { add: vi.fn() }
|
||||
}
|
||||
|
||||
function createMockWidget(value?: unknown) {
|
||||
type PendingInfo = { name: string; notes: string; filePath: string; activeTab?: string } | null
|
||||
const widget = {
|
||||
serializeValue: (async () => null) as () => Promise<unknown>,
|
||||
value: (value ?? undefined) as unknown,
|
||||
onSetValue: undefined as unknown as ((v: unknown) => void),
|
||||
_setLoraInfo: undefined as unknown as (data: Record<string, unknown> | null) => void,
|
||||
__pendingLoraInfo: undefined as unknown as PendingInfo | undefined,
|
||||
}
|
||||
return widget
|
||||
}
|
||||
|
||||
interface MountOptions {
|
||||
initialValue?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type TestWidget = ReturnType<typeof createMockWidget>
|
||||
|
||||
function mountWidget(options: MountOptions = {}) {
|
||||
const fetchApi = createMockFetchApi()
|
||||
const widget = createMockWidget(options.initialValue)
|
||||
const node = { id: 1 }
|
||||
const app = { extensionManager: { toast: createMockToast() } }
|
||||
|
||||
const wrapper = shallowMount(LoraInfoWidget, {
|
||||
props: { widget, node, api: { fetchApi }, app },
|
||||
})
|
||||
|
||||
return { wrapper, widget: widget as TestWidget, fetchApi, app }
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
describe('LoraInfoWidget', () => {
|
||||
beforeEach(() => {
|
||||
setupFetchMock()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetFetchMock()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('shows placeholder when no LoRA is selected', () => {
|
||||
const { wrapper } = mountWidget()
|
||||
expect(wrapper.text()).toContain('No LoRA selected')
|
||||
})
|
||||
|
||||
it('shows Notes tab by default when LoRA is set', async () => {
|
||||
const { wrapper, widget } = mountWidget()
|
||||
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('test.safetensors')
|
||||
expect(wrapper.find('.notes-tab').isVisible()).toBe(true)
|
||||
expect(wrapper.find('.description-tab').isVisible()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tab switching', () => {
|
||||
it('switches to Description tab and back to Notes', async () => {
|
||||
const { wrapper, widget } = mountWidget()
|
||||
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
|
||||
await nextTick()
|
||||
|
||||
const tabs = wrapper.findAll('.lora-info-tab')
|
||||
|
||||
// Click Description tab
|
||||
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||
await descriptionTab.setValue('description')
|
||||
await nextTick()
|
||||
|
||||
expect(tabs[1].classes()).toContain('active')
|
||||
expect(wrapper.text()).toContain('No description available')
|
||||
|
||||
// Switch back to Notes
|
||||
const notesTab = wrapper.findAll('.lora-info-tab-input')[0]
|
||||
await notesTab.setValue('notes')
|
||||
await nextTick()
|
||||
|
||||
expect(tabs[0].classes()).toContain('active')
|
||||
expect(wrapper.text()).toContain('test.safetensors')
|
||||
})
|
||||
})
|
||||
|
||||
describe('description lazy loading', () => {
|
||||
it('fetches metadata when Description tab is activated', async () => {
|
||||
const fetchApi = createMockFetchApi({
|
||||
response: {
|
||||
success: true,
|
||||
metadata: {
|
||||
description: '<p>Version desc</p>',
|
||||
model: { description: '<p>Model desc</p>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
const widget = createMockWidget()
|
||||
const wrapper = shallowMount(LoraInfoWidget, {
|
||||
props: {
|
||||
widget,
|
||||
node: { id: 1 },
|
||||
api: { fetchApi },
|
||||
app: { extensionManager: { toast: createMockToast() } },
|
||||
},
|
||||
})
|
||||
|
||||
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
|
||||
await nextTick()
|
||||
|
||||
// Switch to Description tab
|
||||
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||
await descriptionTab.setValue('description')
|
||||
await nextTick()
|
||||
await nextTick() // flush async fetch
|
||||
|
||||
expect(fetchApi).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/lm/loras/metadata'),
|
||||
expect.objectContaining({ method: 'GET' })
|
||||
)
|
||||
expect(wrapper.html()).toContain('Version desc')
|
||||
expect(wrapper.html()).toContain('Model desc')
|
||||
})
|
||||
|
||||
it('shows loading state while fetching', async () => {
|
||||
// Use a never-resolving promise to simulate loading
|
||||
const fetchApi = vi.fn().mockReturnValue(new Promise(() => {}))
|
||||
const widget = createMockWidget()
|
||||
const wrapper = shallowMount(LoraInfoWidget, {
|
||||
props: {
|
||||
widget,
|
||||
node: { id: 1 },
|
||||
api: { fetchApi },
|
||||
app: { extensionManager: { toast: createMockToast() } },
|
||||
},
|
||||
})
|
||||
|
||||
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
|
||||
await nextTick()
|
||||
|
||||
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||
await descriptionTab.setValue('description')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Loading description')
|
||||
})
|
||||
|
||||
it('shows error state when fetch fails', async () => {
|
||||
const fetchApi = vi.fn().mockRejectedValue(new Error('Network error'))
|
||||
const widget = createMockWidget()
|
||||
const wrapper = shallowMount(LoraInfoWidget, {
|
||||
props: {
|
||||
widget,
|
||||
node: { id: 1 },
|
||||
api: { fetchApi },
|
||||
app: { extensionManager: { toast: createMockToast() } },
|
||||
},
|
||||
})
|
||||
|
||||
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
|
||||
await nextTick()
|
||||
|
||||
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||
await descriptionTab.setValue('description')
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Failed to load description')
|
||||
})
|
||||
|
||||
it('shows empty state when metadata has no descriptions', async () => {
|
||||
const fetchApi = createMockFetchApi({
|
||||
response: {
|
||||
success: true,
|
||||
metadata: {
|
||||
description: '',
|
||||
model: {},
|
||||
},
|
||||
},
|
||||
})
|
||||
const widget = createMockWidget()
|
||||
const wrapper = shallowMount(LoraInfoWidget, {
|
||||
props: {
|
||||
widget,
|
||||
node: { id: 1 },
|
||||
api: { fetchApi },
|
||||
app: { extensionManager: { toast: createMockToast() } },
|
||||
},
|
||||
})
|
||||
|
||||
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
|
||||
await nextTick()
|
||||
|
||||
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||
await descriptionTab.setValue('description')
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('No description available')
|
||||
})
|
||||
|
||||
it('caches description and does not re-fetch on second activation', async () => {
|
||||
const fetchApi = createMockFetchApi({
|
||||
response: {
|
||||
success: true,
|
||||
metadata: {
|
||||
description: '<p>Version desc</p>',
|
||||
model: { description: '<p>Model desc</p>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
const widget = createMockWidget()
|
||||
const wrapper = shallowMount(LoraInfoWidget, {
|
||||
props: {
|
||||
widget,
|
||||
node: { id: 1 },
|
||||
api: { fetchApi },
|
||||
app: { extensionManager: { toast: createMockToast() } },
|
||||
},
|
||||
})
|
||||
|
||||
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
|
||||
await nextTick()
|
||||
|
||||
// First activation
|
||||
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||
await descriptionTab.setValue('description')
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(fetchApi).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Switch away and back
|
||||
const notesTab = wrapper.findAll('.lora-info-tab-input')[0]
|
||||
await notesTab.setValue('notes')
|
||||
await nextTick()
|
||||
await descriptionTab.setValue('description')
|
||||
await nextTick()
|
||||
|
||||
// Should NOT have called fetch again
|
||||
expect(fetchApi).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('re-fetches when LoRA selection changes', async () => {
|
||||
const fetchApi = createMockFetchApi({
|
||||
response: {
|
||||
success: true,
|
||||
metadata: {
|
||||
description: '<p>Version desc</p>',
|
||||
model: { description: '<p>Model desc</p>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
const widget = createMockWidget()
|
||||
const wrapper = shallowMount(LoraInfoWidget, {
|
||||
props: {
|
||||
widget,
|
||||
node: { id: 1 },
|
||||
api: { fetchApi },
|
||||
app: { extensionManager: { toast: createMockToast() } },
|
||||
},
|
||||
})
|
||||
|
||||
widget._setLoraInfo!({ name: 'first.safetensors', notes: '', filePath: '/path/first.safetensors' })
|
||||
await nextTick()
|
||||
|
||||
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||
await descriptionTab.setValue('description')
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(fetchApi).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Select a different LoRA — resets description state
|
||||
widget._setLoraInfo!({ name: 'second.safetensors', notes: '', filePath: '/path/second.safetensors' })
|
||||
await nextTick()
|
||||
|
||||
// Should show loading again (not cached)
|
||||
await descriptionTab.setValue('description')
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(fetchApi).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('serialization roundtrip', () => {
|
||||
it('serializeValue includes activeTab', async () => {
|
||||
const { wrapper, widget } = mountWidget()
|
||||
widget._setLoraInfo!({ name: 'test.safetensors', notes: 'my notes', filePath: '/path/test.safetensors' })
|
||||
await nextTick()
|
||||
|
||||
// Switch to Description tab
|
||||
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||
await descriptionTab.setValue('description')
|
||||
await nextTick()
|
||||
|
||||
const serialized = await widget.serializeValue!()
|
||||
expect(serialized).toMatchObject({
|
||||
name: 'test.safetensors',
|
||||
notes: 'my notes',
|
||||
filePath: '/path/test.safetensors',
|
||||
activeTab: 'description',
|
||||
})
|
||||
})
|
||||
|
||||
it('onSetValue restores activeTab from workflow value', async () => {
|
||||
const { wrapper } = mountWidget({
|
||||
initialValue: {
|
||||
name: 'saved.safetensors',
|
||||
notes: 'saved notes',
|
||||
filePath: '/path/saved.safetensors',
|
||||
activeTab: 'description',
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Description tab should be visible (activeTab restored to 'description')
|
||||
expect(wrapper.find('.description-tab').isVisible()).toBe(true)
|
||||
expect(wrapper.text()).toContain('saved.safetensors')
|
||||
})
|
||||
|
||||
it('defaults to notes tab when activeTab is missing in saved value', async () => {
|
||||
const { wrapper } = mountWidget({
|
||||
initialValue: {
|
||||
name: 'legacy.safetensors',
|
||||
notes: 'legacy notes',
|
||||
filePath: '/path/legacy.safetensors',
|
||||
// No activeTab — legacy workflow
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.notes-tab').isVisible()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('_setLoraInfo race condition guard', () => {
|
||||
it('consumes __pendingLoraInfo pushed before mount', async () => {
|
||||
const widget = createMockWidget()
|
||||
widget.__pendingLoraInfo = {
|
||||
name: 'pending.safetensors',
|
||||
notes: 'pending notes',
|
||||
filePath: '/path/pending.safetensors',
|
||||
}
|
||||
|
||||
const wrapper = shallowMount(LoraInfoWidget, {
|
||||
props: {
|
||||
widget,
|
||||
node: { id: 1 },
|
||||
api: { fetchApi: createMockFetchApi() },
|
||||
app: { extensionManager: { toast: createMockToast() } },
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(widget.__pendingLoraInfo).toBeUndefined()
|
||||
expect(wrapper.text()).toContain('pending.safetensors')
|
||||
})
|
||||
|
||||
it('preserves activeTab when _setLoraInfo called with null (deselection)', async () => {
|
||||
const { wrapper, widget } = mountWidget()
|
||||
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
|
||||
await nextTick()
|
||||
|
||||
// Switch to Description tab
|
||||
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||
await descriptionTab.setValue('description')
|
||||
await nextTick()
|
||||
|
||||
// Deselect — template shows placeholder (no tab bar rendered)
|
||||
widget._setLoraInfo!(null)
|
||||
await nextTick()
|
||||
|
||||
// Placeholder shown
|
||||
expect(wrapper.text()).toContain('No LoRA selected')
|
||||
|
||||
// Re-select — activeTab should still be 'description'
|
||||
widget._setLoraInfo!({ name: 'second.safetensors', notes: '', filePath: '/path/second.safetensors' })
|
||||
await nextTick()
|
||||
|
||||
const tabs = wrapper.findAll('.lora-info-tab')
|
||||
expect(tabs[1].classes()).toContain('active')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user