Files
ComfyUI_frontend/src/components/sidebar/tabs/ModelLibrarySidebarTab.test.ts
pythongosssss e97cca9e4a feat: show node preview ghost when adding models from dialog & sidebar (#12765)
## Summary

Adds consistent ghost node behavior when clicking "use" on models from
either the model dialog or treeview - matching the Node Library & Node
Search.

## Changes

- **What**: 
- Split `createModelNodeFromAsset` into `resolveModelNodeFromAsset` and
`startModelNodeDragFromAsset` to allow sharing logic between the two
drag sources
- Move NodeDragPreview file & use from being node library tab specific
to app level in GraphCanvas so all drag sources share it
- drag listeners now attach on `startDrag` and detach on cancel instead
of living for the tab's lifetime
- Updated `LGraphNodePreview` to accept widgetValues and prepend combo
options with passed value so it shows in the preview as the default

## Review Focus
- refactored positioning to use RAF with useMouse and transform to fix
laggy-follow behavior present in Firefox

## Screenshots (if applicable)

Model dialog + Node library

https://github.com/user-attachments/assets/b227ac43-c6ea-4cf6-86ed-6cfb196fd80e

Model library sidebar

https://github.com/user-attachments/assets/bb546aee-5099-4df9-abe5-68bccd8fa2eb
2026-06-23 10:41:14 +00:00

240 lines
6.3 KiB
TypeScript

import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import type { TreeExplorerNode } from '@/types/treeExplorerTypes'
import type { ComfyModelDef } from '@/stores/modelStore'
import ModelLibrarySidebarTab from './ModelLibrarySidebarTab.vue'
const {
captureRoot,
getRoot,
resetRoot,
mockStartDrag,
mockGetNodeProvider,
mockToggleNodeOnEvent,
mockRefreshModelFolder,
downloadStoreState
} = vi.hoisted(() => {
let capturedRoot: TreeExplorerNode<unknown> | null = null
return {
captureRoot: (root: TreeExplorerNode<unknown>) => {
capturedRoot = root
},
getRoot: () => capturedRoot as TreeExplorerNode<ComfyModelDef>,
resetRoot: () => {
capturedRoot = null
},
mockStartDrag: vi.fn(),
mockGetNodeProvider: vi.fn(),
mockToggleNodeOnEvent: vi.fn(),
mockRefreshModelFolder: vi.fn().mockResolvedValue(undefined),
downloadStoreState: { setLastCompleted: (_: unknown) => {} }
}
})
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
useNodeDragToCanvas: () => ({ startDrag: mockStartDrag })
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({ getNodeProvider: mockGetNodeProvider })
}))
const mockModel = fromPartial<ComfyModelDef>({
key: 'checkpoints/model.safetensors',
file_name: 'model.safetensors',
simplified_file_name: 'model',
title: 'Model',
directory: 'checkpoints',
searchable: 'checkpoints/model.safetensors'
})
vi.mock('@/stores/modelStore', () => ({
ResourceState: {
Loading: 'loading',
Loaded: 'loaded'
},
useModelStore: () => ({
modelFolders: [],
models: [mockModel],
loadModels: vi.fn().mockResolvedValue([]),
loadModelFolders: vi.fn().mockResolvedValue([]),
refresh: vi.fn().mockResolvedValue(undefined),
refreshModelFolder: mockRefreshModelFolder
})
}))
vi.mock('@/stores/assetDownloadStore', async () => {
const { ref } = await import('vue')
const lastCompletedDownload = ref<{
taskId: string
modelType: string
timestamp: number
} | null>(null)
downloadStoreState.setLastCompleted = (value) => {
lastCompletedDownload.value = value as typeof lastCompletedDownload.value
}
return {
useAssetDownloadStore: () => ({
get lastCompletedDownload() {
return lastCompletedDownload.value
}
})
}
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.ModelLibrary.NameFormat') return 'filename'
return false
})
})
}))
vi.mock('@/composables/useTreeExpansion', () => ({
useTreeExpansion: () => ({
expandNode: vi.fn(),
toggleNodeOnEvent: mockToggleNodeOnEvent
})
}))
vi.mock('@/components/common/TreeExplorer.vue', () => ({
default: {
name: 'TreeExplorer',
template: '<div data-testid="tree-explorer" />',
props: ['root', 'expandedKeys'],
setup(props: { root: TreeExplorerNode<unknown> }) {
captureRoot(props.root)
}
}
}))
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
default: {
name: 'SearchInput',
template: '<input data-testid="search-input" />',
props: ['modelValue', 'placeholder'],
setup() {
return { focus: vi.fn() }
},
expose: ['focus']
}
}))
vi.mock('./SidebarTopArea.vue', () => ({
default: { name: 'SidebarTopArea', template: '<div><slot /></div>' }
}))
vi.mock('./SidebarTabTemplate.vue', () => ({
default: {
name: 'SidebarTabTemplate',
template: '<div><slot name="header" /><slot name="body" /></div>'
}
}))
vi.mock('./modelLibrary/ElectronDownloadItems.vue', () => ({
default: { name: 'ElectronDownloadItems', template: '<div />' }
}))
vi.mock('./modelLibrary/ModelTreeLeaf.vue', () => ({
default: { name: 'ModelTreeLeaf', template: '<div />', props: ['node'] }
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
describe('ModelLibrarySidebarTab', () => {
beforeEach(() => {
vi.clearAllMocks()
resetRoot()
downloadStoreState.setLastCompleted(null)
})
function renderComponent() {
return render(ModelLibrarySidebarTab, {
global: {
plugins: [createTestingPinia({ stubActions: false }), i18n],
stubs: { teleport: true }
}
})
}
it('renders search input', () => {
renderComponent()
expect(screen.getByTestId('search-input')).toBeInTheDocument()
})
it('starts a ghost drag carrying the widget value to fill on placement', async () => {
const mockNodeDef = { name: 'CheckpointLoaderSimple' }
mockGetNodeProvider.mockReturnValue({
nodeDef: mockNodeDef,
key: 'ckpt_name'
})
renderComponent()
await nextTick()
const root = getRoot()
const checkpointsFolder = root.children?.[0]
const modelLeaf = checkpointsFolder?.children?.[0]
expect(modelLeaf?.label).toBe('model')
expect(modelLeaf?.leaf).toBe(true)
const mockEvent = new MouseEvent('click')
await modelLeaf?.handleClick?.(mockEvent)
expect(mockGetNodeProvider).toHaveBeenCalledWith('checkpoints')
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, {
widgetValues: { ckpt_name: 'model.safetensors' },
source: 'sidebar_drag'
})
})
it('toggles folder expansion on click', async () => {
renderComponent()
await nextTick()
const root = getRoot()
const checkpointsFolder = root.children?.[0]
const mockEvent = new MouseEvent('click')
await checkpointsFolder?.handleClick?.(mockEvent)
expect(mockToggleNodeOnEvent).toHaveBeenCalled()
})
it('refreshes the affected folder when an asset download completes', async () => {
renderComponent()
await nextTick()
expect(mockRefreshModelFolder).not.toHaveBeenCalled()
downloadStoreState.setLastCompleted({
taskId: 'task-1',
modelType: 'checkpoints',
timestamp: Date.now()
})
await nextTick()
expect(mockRefreshModelFolder).toHaveBeenCalledWith('checkpoints')
})
it('does not refresh when no download has completed', async () => {
renderComponent()
await nextTick()
expect(mockRefreshModelFolder).not.toHaveBeenCalled()
})
})