chore: migrate tests from tests-ui/ to colocate with source files (#7811)

## Summary

Migrates all unit tests from `tests-ui/` to colocate with their source
files in `src/`, improving discoverability and maintainability.

## Changes

- **What**: Relocated all unit tests to be adjacent to the code they
test, following the `<source>.test.ts` naming convention
- **Config**: Updated `vitest.config.ts` to remove `tests-ui` include
pattern and `@tests-ui` alias
- **Docs**: Moved testing documentation to `docs/testing/` with updated
paths and patterns

## Review Focus

- Migration patterns documented in
`temp/plans/migrate-tests-ui-to-src.md`
- Tests use `@/` path aliases instead of relative imports
- Shared fixtures placed in `__fixtures__/` directories

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7811-chore-migrate-tests-from-tests-ui-to-colocate-with-source-files-2da6d73d36508147a4cce85365dee614)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Alexander Brown
2026-01-05 16:32:24 -08:00
committed by GitHub
parent 832588c7a9
commit 10feb1fd5b
272 changed files with 483 additions and 1239 deletions

View File

@@ -0,0 +1,927 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, shallowRef } from 'vue'
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0))
const triggerRAF = async () => {
// Trigger all RAF callbacks
Object.values(rafCallbacks).forEach((cb) => cb?.())
await flushPromises()
}
const mockPause = vi.fn()
const mockResume = vi.fn()
const rafCallbacks: Record<string, () => void> = {}
let rafCallbackId = 0
vi.mock('@vueuse/core', () => {
return {
useRafFn: vi.fn((callback, options) => {
const id = rafCallbackId++
rafCallbacks[id] = callback
if (options?.immediate !== false) {
void Promise.resolve().then(() => callback())
}
const resumeFn = vi.fn(() => {
mockResume()
// Execute the RAF callback immediately when resumed
if (rafCallbacks[id]) {
rafCallbacks[id]()
}
})
return {
pause: mockPause,
resume: resumeFn
}
}),
useThrottleFn: vi.fn((callback) => {
return (...args: any[]) => {
return callback(...args)
}
})
}
})
let mockCanvas: any
let mockGraph: any
const setupMocks = () => {
const mockNodes = [
{
id: 'node1',
pos: [0, 0],
size: [100, 50],
color: '#ff0000',
constructor: { color: '#666' },
outputs: [
{
links: ['link1']
}
]
},
{
id: 'node2',
pos: [200, 100],
size: [150, 75],
constructor: { color: '#666' },
outputs: []
}
]
mockGraph = {
_nodes: mockNodes,
links: {
link1: {
id: 'link1',
target_id: 'node2'
}
},
getNodeById: vi.fn((id) => mockNodes.find((n) => n.id === id)),
setDirtyCanvas: vi.fn(),
onNodeAdded: null,
onNodeRemoved: null,
onConnectionChange: null
}
mockCanvas = {
graph: mockGraph,
canvas: {
width: 1000,
height: 800,
clientWidth: 1000,
clientHeight: 800
},
ds: {
scale: 1,
offset: [0, 0]
},
setDirty: vi.fn()
}
}
setupMocks()
const defaultCanvasStore = {
canvas: mockCanvas,
getCanvas: () => defaultCanvasStore.canvas
}
const defaultSettingStore = {
get: vi.fn().mockReturnValue(true),
set: vi.fn().mockResolvedValue(undefined)
}
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn(() => defaultCanvasStore)
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => defaultSettingStore)
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: vi.fn(() => ({
completedActivePalette: {
light_theme: false
}
}))
}))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
apiURL: vi.fn().mockReturnValue('http://localhost:8188')
}
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
graph: mockGraph
}
}
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
activeSubgraph: null
}))
}))
const { useMinimap } =
await import('@/renderer/extensions/minimap/composables/useMinimap')
const { api } = await import('@/scripts/api')
describe('useMinimap', () => {
let mockCanvas: any
let mockGraph: any
let mockCanvasElement: any
let mockContainerElement: any
let mockContext2D: any
async function createAndInitializeMinimap() {
const minimap = useMinimap({
containerRefMaybe: shallowRef(mockContainerElement),
canvasRefMaybe: shallowRef(mockCanvasElement)
})
await minimap.init()
await nextTick()
await flushPromises()
return minimap
}
beforeEach(() => {
vi.clearAllMocks()
mockPause.mockClear()
mockResume.mockClear()
mockContext2D = {
clearRect: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
arc: vi.fn(),
fill: vi.fn(),
fillStyle: '',
strokeStyle: '',
lineWidth: 0
}
mockCanvasElement = {
getContext: vi.fn().mockReturnValue(mockContext2D),
width: 250,
height: 200,
clientWidth: 250,
clientHeight: 200
}
const mockRect = {
left: 100,
top: 100,
width: 250,
height: 200,
right: 350,
bottom: 300,
x: 100,
y: 100
}
mockContainerElement = {
getBoundingClientRect: vi.fn(() => ({ ...mockRect }))
}
const mockNodes = [
{
id: 'node1',
pos: [0, 0],
size: [100, 50],
color: '#ff0000',
constructor: { color: '#666' },
outputs: [
{
links: ['link1']
}
]
},
{
id: 'node2',
pos: [200, 100],
size: [150, 75],
constructor: { color: '#666' },
outputs: []
}
]
mockGraph = {
_nodes: mockNodes,
links: {
link1: {
id: 'link1',
target_id: 'node2'
}
},
getNodeById: vi.fn((id) => mockNodes.find((n) => n.id === id)),
setDirtyCanvas: vi.fn(),
onNodeAdded: null,
onNodeRemoved: null,
onConnectionChange: null
}
mockCanvas = {
graph: mockGraph,
canvas: {
width: 1000,
height: 800,
clientWidth: 1000,
clientHeight: 800
},
ds: {
scale: 1,
offset: [0, 0]
},
setDirty: vi.fn()
}
defaultCanvasStore.canvas = mockCanvas
defaultSettingStore.get = vi.fn().mockReturnValue(true)
defaultSettingStore.set = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(window, 'devicePixelRatio', {
writable: true,
configurable: true,
value: 1
})
window.addEventListener = vi.fn()
window.removeEventListener = vi.fn()
})
describe('initialization', () => {
it('should initialize with default values', () => {
const originalCanvas = defaultCanvasStore.canvas
defaultCanvasStore.canvas = null
const minimap = useMinimap()
expect(minimap.width).toBe(250)
expect(minimap.height).toBe(200)
expect(minimap.visible.value).toBe(true)
expect(minimap.initialized.value).toBe(false)
defaultCanvasStore.canvas = originalCanvas
})
it('should initialize minimap when canvas is available', async () => {
const minimap = await createAndInitializeMinimap()
await minimap.init()
expect(minimap.initialized.value).toBe(true)
expect(defaultSettingStore.get).toHaveBeenCalledWith(
'Comfy.Minimap.Visible'
)
expect(api.addEventListener).toHaveBeenCalledWith(
'graphChanged',
expect.any(Function)
)
if (minimap.visible.value) {
expect(mockResume).toHaveBeenCalled()
}
})
it('should not initialize without canvas and graph', async () => {
const originalCanvas = defaultCanvasStore.canvas
defaultCanvasStore.canvas = null
const minimap = useMinimap()
await minimap.init()
expect(minimap.initialized.value).toBe(false)
expect(api.addEventListener).not.toHaveBeenCalled()
defaultCanvasStore.canvas = originalCanvas
})
it('should setup event listeners on graph', async () => {
const minimap = await createAndInitializeMinimap()
await minimap.init()
expect(mockGraph.onNodeAdded).toBeDefined()
expect(mockGraph.onNodeRemoved).toBeDefined()
expect(mockGraph.onConnectionChange).toBeDefined()
})
it('should handle visibility from settings', async () => {
defaultSettingStore.get.mockReturnValue(false)
const minimap = await createAndInitializeMinimap()
await minimap.init()
expect(minimap.visible.value).toBe(false)
expect(mockResume).not.toHaveBeenCalled()
})
})
describe('destroy', () => {
it('should cleanup all resources', async () => {
const minimap = await createAndInitializeMinimap()
await minimap.init()
minimap.destroy()
expect(mockPause).toHaveBeenCalled()
expect(api.removeEventListener).toHaveBeenCalledWith(
'graphChanged',
expect.any(Function)
)
expect(window.removeEventListener).toHaveBeenCalled()
expect(minimap.initialized.value).toBe(false)
})
it('should restore original graph callbacks', async () => {
const originalCallbacks = {
onNodeAdded: vi.fn(),
onNodeRemoved: vi.fn(),
onConnectionChange: vi.fn()
}
mockGraph.onNodeAdded = originalCallbacks.onNodeAdded
mockGraph.onNodeRemoved = originalCallbacks.onNodeRemoved
mockGraph.onConnectionChange = originalCallbacks.onConnectionChange
const minimap = await createAndInitializeMinimap()
await minimap.init()
minimap.destroy()
expect(mockGraph.onNodeAdded).toBe(originalCallbacks.onNodeAdded)
expect(mockGraph.onNodeRemoved).toBe(originalCallbacks.onNodeRemoved)
expect(mockGraph.onConnectionChange).toBe(
originalCallbacks.onConnectionChange
)
})
})
describe('toggle', () => {
it('should toggle visibility and save to settings', async () => {
const minimap = useMinimap()
const initialVisibility = minimap.visible.value
await minimap.toggle()
expect(minimap.visible.value).toBe(!initialVisibility)
expect(defaultSettingStore.set).toHaveBeenCalledWith(
'Comfy.Minimap.Visible',
!initialVisibility
)
await minimap.toggle()
expect(minimap.visible.value).toBe(initialVisibility)
expect(defaultSettingStore.set).toHaveBeenCalledWith(
'Comfy.Minimap.Visible',
initialVisibility
)
})
})
describe('rendering', () => {
it('should verify context is obtained during render', async () => {
const minimap = await createAndInitializeMinimap()
const getContextSpy = vi.spyOn(mockCanvasElement, 'getContext')
await minimap.init()
// Force initial render
minimap.renderMinimap()
// Force a render by triggering a graph change
mockGraph._nodes.push({
id: 'new-node',
pos: [150, 150],
size: [100, 50]
})
// Trigger RAF to process changes
await triggerRAF()
await nextTick()
expect(getContextSpy).toHaveBeenCalled()
expect(getContextSpy).toHaveBeenCalledWith('2d')
})
it('should render at least once after initialization', async () => {
const minimap = await createAndInitializeMinimap()
await minimap.init()
// Force initial render
minimap.renderMinimap()
// Force a render by modifying a node position
mockGraph._nodes[0].pos = [50, 50]
// Trigger RAF to process changes
await triggerRAF()
await nextTick()
const renderingOccurred =
mockContext2D.clearRect.mock.calls.length > 0 ||
mockContext2D.fillRect.mock.calls.length > 0
if (!renderingOccurred) {
console.log('Minimap visible:', minimap.visible.value)
console.log('Minimap initialized:', minimap.initialized.value)
console.log('Canvas exists:', !!defaultCanvasStore.canvas)
console.log('Graph exists:', !!defaultCanvasStore.canvas?.graph)
console.log(
'clearRect calls:',
mockContext2D.clearRect.mock.calls.length
)
console.log('fillRect calls:', mockContext2D.fillRect.mock.calls.length)
console.log(
'getContext calls:',
mockCanvasElement.getContext.mock.calls.length
)
}
expect(renderingOccurred).toBe(true)
})
it('should not render when context is null', async () => {
mockCanvasElement.getContext = vi.fn().mockReturnValue(null)
const minimap = await createAndInitializeMinimap()
await minimap.init()
await new Promise((resolve) => setTimeout(resolve, 100))
expect(mockContext2D.clearRect).not.toHaveBeenCalled()
mockCanvasElement.getContext = vi.fn().mockReturnValue(mockContext2D)
})
it('should handle empty graph', async () => {
const originalNodes = [...mockGraph._nodes]
mockGraph._nodes = []
const minimap = await createAndInitializeMinimap()
await minimap.init()
// The renderer has a fast path for empty graphs, force it to execute
minimap.renderMinimap()
await new Promise((resolve) => setTimeout(resolve, 100))
expect(minimap.initialized.value).toBe(true)
// With the new reactive system, the minimap may still render some elements
// The key test is that it doesn't crash and properly initializes
expect(mockContext2D.clearRect).toHaveBeenCalled()
mockGraph._nodes = originalNodes
})
})
describe('pointer interactions', () => {
it('should handle pointer down and start dragging (mouse)', async () => {
const minimap = await createAndInitializeMinimap()
const pointerEvent = new PointerEvent('pointerdown', {
clientX: 150,
clientY: 150,
pointerType: 'mouse'
})
minimap.handlePointerDown(pointerEvent)
expect(mockContainerElement.getBoundingClientRect).toHaveBeenCalled()
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('should handle pointer move while dragging (mouse)', async () => {
const minimap = await createAndInitializeMinimap()
const pointerDownEvent = new PointerEvent('pointerdown', {
clientX: 150,
clientY: 150,
pointerType: 'mouse'
})
minimap.handlePointerDown(pointerDownEvent)
const pointerMoveEvent = new PointerEvent('pointermove', {
clientX: 200,
clientY: 200,
pointerType: 'mouse'
})
minimap.handlePointerMove(pointerMoveEvent)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
expect(mockCanvas.ds.offset).toBeDefined()
})
it('should handle pointer up to stop dragging (mouse)', async () => {
const minimap = await createAndInitializeMinimap()
const pointerDownEvent = new PointerEvent('pointerdown', {
clientX: 150,
clientY: 150,
pointerType: 'mouse'
})
minimap.handlePointerDown(pointerDownEvent)
minimap.handlePointerUp()
mockCanvas.setDirty.mockClear()
const pointerMoveEvent = new PointerEvent('pointermove', {
clientX: 200,
clientY: 200,
pointerType: 'mouse'
})
minimap.handlePointerMove(pointerMoveEvent)
expect(mockCanvas.setDirty).not.toHaveBeenCalled()
})
it('should handle pointer down and start dragging (touch)', async () => {
const minimap = await createAndInitializeMinimap()
const pointerEvent = new PointerEvent('pointerdown', {
clientX: 150,
clientY: 150,
pointerType: 'touch'
})
minimap.handlePointerDown(pointerEvent)
expect(mockContainerElement.getBoundingClientRect).toHaveBeenCalled()
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('should handle pointer move while dragging (touch)', async () => {
const minimap = await createAndInitializeMinimap()
const pointerDownEvent = new PointerEvent('pointerdown', {
clientX: 150,
clientY: 150,
pointerType: 'touch'
})
minimap.handlePointerDown(pointerDownEvent)
const pointerMoveEvent = new PointerEvent('pointermove', {
clientX: 200,
clientY: 200,
pointerType: 'touch'
})
minimap.handlePointerMove(pointerMoveEvent)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
expect(mockCanvas.ds.offset).toBeDefined()
})
it('should handle pointer move while dragging (pen)', async () => {
const minimap = await createAndInitializeMinimap()
const pointerDownEvent = new PointerEvent('pointerdown', {
clientX: 150,
clientY: 150,
pointerType: 'pen'
})
minimap.handlePointerDown(pointerDownEvent)
const pointerMoveEvent = new PointerEvent('pointermove', {
clientX: 200,
clientY: 200,
pointerType: 'pen'
})
minimap.handlePointerMove(pointerMoveEvent)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
expect(mockCanvas.ds.offset).toBeDefined()
})
it('should not move when not dragging with pointer', async () => {
const minimap = await createAndInitializeMinimap()
mockCanvas.setDirty.mockClear()
const pointerMoveEvent = new PointerEvent('pointermove', {
clientX: 200,
clientY: 200,
pointerType: 'touch'
})
minimap.handlePointerMove(pointerMoveEvent)
expect(mockCanvas.setDirty).not.toHaveBeenCalled()
})
it('should handle pointer up to stop dragging (touch)', async () => {
const minimap = await createAndInitializeMinimap()
const pointerDownEvent = new PointerEvent('pointerdown', {
clientX: 150,
clientY: 150,
pointerType: 'touch'
})
minimap.handlePointerDown(pointerDownEvent)
minimap.handlePointerUp()
mockCanvas.setDirty.mockClear()
const pointerMoveEvent = new PointerEvent('pointermove', {
clientX: 200,
clientY: 200,
pointerType: 'touch'
})
minimap.handlePointerMove(pointerMoveEvent)
expect(mockCanvas.setDirty).not.toHaveBeenCalled()
})
})
describe('wheel interactions', () => {
it('should handle wheel zoom in', async () => {
const minimap = await createAndInitializeMinimap()
await minimap.init()
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100,
clientX: 150,
clientY: 150
})
const preventDefault = vi.fn()
Object.defineProperty(wheelEvent, 'preventDefault', {
value: preventDefault
})
minimap.handleWheel(wheelEvent)
expect(preventDefault).toHaveBeenCalled()
expect(mockCanvas.ds.scale).toBeCloseTo(1.1)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('should handle wheel zoom out', async () => {
const minimap = await createAndInitializeMinimap()
await minimap.init()
const wheelEvent = new WheelEvent('wheel', {
deltaY: 100,
clientX: 150,
clientY: 150
})
const preventDefault = vi.fn()
Object.defineProperty(wheelEvent, 'preventDefault', {
value: preventDefault
})
minimap.handleWheel(wheelEvent)
expect(preventDefault).toHaveBeenCalled()
expect(mockCanvas.ds.scale).toBeCloseTo(0.9)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('should respect zoom limits', async () => {
const minimap = await createAndInitializeMinimap()
await minimap.init()
mockCanvas.ds.scale = 0.1
const wheelEvent = new WheelEvent('wheel', {
deltaY: 100,
clientX: 150,
clientY: 150
})
const preventDefault = vi.fn()
Object.defineProperty(wheelEvent, 'preventDefault', {
value: preventDefault
})
minimap.handleWheel(wheelEvent)
expect(mockCanvas.ds.scale).toBe(0.1)
})
it('should update container rect if needed', async () => {
const minimap = await createAndInitializeMinimap()
await minimap.init()
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100,
clientX: 150,
clientY: 150
})
const preventDefault = vi.fn()
Object.defineProperty(wheelEvent, 'preventDefault', {
value: preventDefault
})
minimap.handleWheel(wheelEvent)
expect(mockContainerElement.getBoundingClientRect).toHaveBeenCalled()
})
})
describe('viewport updates', () => {
it('should update viewport transform correctly', async () => {
const minimap = await createAndInitializeMinimap()
await minimap.init()
await nextTick()
const viewportStyles = minimap.viewportStyles.value
expect(viewportStyles).toBeDefined()
expect(viewportStyles.transform).toMatch(
/translate\(-?\d+(\.\d+)?px, -?\d+(\.\d+)?px\)/
)
expect(viewportStyles.width).toMatch(/\d+(\.\d+)?px/)
expect(viewportStyles.height).toMatch(/\d+(\.\d+)?px/)
expect(viewportStyles.border).toBe('2px solid #FFF')
})
it('should handle canvas dimension updates', async () => {
const minimap = await createAndInitializeMinimap()
await minimap.init()
mockCanvas.canvas.clientWidth = 1200
mockCanvas.canvas.clientHeight = 900
const resizeHandler = (window.addEventListener as any).mock.calls.find(
(call: any) => call[0] === 'resize'
)?.[1]
if (resizeHandler) {
resizeHandler()
}
await nextTick()
expect(minimap.viewportStyles.value).toBeDefined()
})
})
describe('graph change handling', () => {
it('should handle node addition', async () => {
const minimap = await createAndInitializeMinimap()
await minimap.init()
const newNode = {
id: 'node3',
pos: [300, 200],
size: [100, 100],
constructor: { color: '#666' }
}
mockGraph._nodes.push(newNode)
if (mockGraph.onNodeAdded) {
mockGraph.onNodeAdded(newNode)
}
await new Promise((resolve) => setTimeout(resolve, 600))
})
it('should handle node removal', async () => {
const minimap = await createAndInitializeMinimap()
await minimap.init()
const removedNode = mockGraph._nodes[0]
mockGraph._nodes.splice(0, 1)
if (mockGraph.onNodeRemoved) {
mockGraph.onNodeRemoved(removedNode)
}
await new Promise((resolve) => setTimeout(resolve, 600))
})
it('should handle connection changes', async () => {
const minimap = await createAndInitializeMinimap()
await minimap.init()
if (mockGraph.onConnectionChange) {
mockGraph.onConnectionChange(mockGraph._nodes[0])
}
await new Promise((resolve) => setTimeout(resolve, 600))
})
})
describe('container styles', () => {
it('should provide correct container styles for dark theme', () => {
const minimap = useMinimap()
const styles = minimap.containerStyles.value
expect(styles.width).toBe('253px')
expect(styles.height).toBe('200px')
expect(styles.border).toBe('1px solid var(--interface-stroke)')
expect(styles.borderRadius).toBe('8px')
})
})
describe('edge cases', () => {
it('should handle missing node outputs', async () => {
mockGraph._nodes[0].outputs = null
const minimap = await createAndInitializeMinimap()
await expect(minimap.init()).resolves.not.toThrow()
expect(minimap.initialized.value).toBe(true)
})
it('should handle invalid link references', async () => {
mockGraph.links.link1.target_id = 'invalid-node'
mockGraph.getNodeById.mockReturnValue(null)
const minimap = await createAndInitializeMinimap()
await expect(minimap.init()).resolves.not.toThrow()
expect(minimap.initialized.value).toBe(true)
})
it('should handle high DPI displays', async () => {
window.devicePixelRatio = 2
const minimap = await createAndInitializeMinimap()
await minimap.init()
expect(minimap.initialized.value).toBe(true)
})
it('should handle nodes without color', async () => {
mockGraph._nodes[0].color = undefined
const minimap = await createAndInitializeMinimap()
await minimap.init()
const renderMinimap = (minimap as any).renderMinimap
if (renderMinimap) {
renderMinimap()
}
expect(mockContext2D.fillRect).toHaveBeenCalled()
expect(mockContext2D.fillStyle).toBeDefined()
})
})
describe('setMinimapRef', () => {
it('should set minimap reference', () => {
const minimap = useMinimap()
const ref = document.createElement('div')
minimap.setMinimapRef(ref)
expect(() => minimap.setMinimapRef(ref)).not.toThrow()
})
})
})

View File

@@ -0,0 +1,299 @@
import { useThrottleFn } from '@vueuse/core'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useMinimapGraph } from '@/renderer/extensions/minimap/composables/useMinimapGraph'
import { api } from '@/scripts/api'
vi.mock('@vueuse/core', () => ({
useThrottleFn: vi.fn((fn) => fn)
}))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
describe('useMinimapGraph', () => {
let mockGraph: LGraph
let onGraphChangedMock: () => void
beforeEach(() => {
vi.clearAllMocks()
mockGraph = {
id: 'test-graph-123',
_nodes: [
{ id: '1', pos: [100, 100], size: [150, 80] },
{ id: '2', pos: [300, 200], size: [120, 60] }
],
links: { link1: { id: 'link1' } },
onNodeAdded: vi.fn(),
onNodeRemoved: vi.fn(),
onConnectionChange: vi.fn()
} as any
onGraphChangedMock = vi.fn()
})
it('should initialize with empty state', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
expect(graphManager.updateFlags.value).toEqual({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
})
it('should setup event listeners on init', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.init()
expect(api.addEventListener).toHaveBeenCalledWith(
'graphChanged',
expect.any(Function)
)
})
it('should wrap graph callbacks on setup', () => {
const originalOnNodeAdded = vi.fn()
const originalOnNodeRemoved = vi.fn()
const originalOnConnectionChange = vi.fn()
mockGraph.onNodeAdded = originalOnNodeAdded
mockGraph.onNodeRemoved = originalOnNodeRemoved
mockGraph.onConnectionChange = originalOnConnectionChange
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
// Should wrap the callbacks
expect(mockGraph.onNodeAdded).not.toBe(originalOnNodeAdded)
expect(mockGraph.onNodeRemoved).not.toBe(originalOnNodeRemoved)
expect(mockGraph.onConnectionChange).not.toBe(originalOnConnectionChange)
// Test wrapped callbacks
const testNode = { id: '3' } as LGraphNode
mockGraph.onNodeAdded!(testNode)
expect(originalOnNodeAdded).toHaveBeenCalledWith(testNode)
expect(onGraphChangedMock).toHaveBeenCalled()
})
it('should prevent duplicate event listener setup', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Store original callbacks for comparison
// const originalCallbacks = {
// onNodeAdded: mockGraph.onNodeAdded,
// onNodeRemoved: mockGraph.onNodeRemoved,
// onConnectionChange: mockGraph.onConnectionChange
// }
graphManager.setupEventListeners()
const wrappedCallbacks = {
onNodeAdded: mockGraph.onNodeAdded,
onNodeRemoved: mockGraph.onNodeRemoved,
onConnectionChange: mockGraph.onConnectionChange
}
// Setup again - should not re-wrap
graphManager.setupEventListeners()
expect(mockGraph.onNodeAdded).toBe(wrappedCallbacks.onNodeAdded)
expect(mockGraph.onNodeRemoved).toBe(wrappedCallbacks.onNodeRemoved)
expect(mockGraph.onConnectionChange).toBe(
wrappedCallbacks.onConnectionChange
)
})
it('should cleanup event listeners properly', () => {
const originalOnNodeAdded = vi.fn()
const originalOnNodeRemoved = vi.fn()
const originalOnConnectionChange = vi.fn()
mockGraph.onNodeAdded = originalOnNodeAdded
mockGraph.onNodeRemoved = originalOnNodeRemoved
mockGraph.onConnectionChange = originalOnConnectionChange
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
graphManager.cleanupEventListeners()
// Should restore original callbacks
expect(mockGraph.onNodeAdded).toBe(originalOnNodeAdded)
expect(mockGraph.onNodeRemoved).toBe(originalOnNodeRemoved)
expect(mockGraph.onConnectionChange).toBe(originalOnConnectionChange)
})
it('should handle cleanup for never-setup graph', () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.cleanupEventListeners()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Attempted to cleanup event listeners for graph that was never set up'
)
consoleErrorSpy.mockRestore()
})
it('should detect node position changes', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// First check - cache initial state
let hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true) // Initial cache population
// No changes
hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(false)
// Change node position
mockGraph._nodes[0].pos = [200, 150]
hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true)
expect(graphManager.updateFlags.value.bounds).toBe(true)
expect(graphManager.updateFlags.value.nodes).toBe(true)
})
it('should detect node count changes', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Cache initial state
graphManager.checkForChanges()
// Add a node
mockGraph._nodes.push({ id: '3', pos: [400, 300], size: [100, 50] } as any)
const hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true)
expect(graphManager.updateFlags.value.bounds).toBe(true)
expect(graphManager.updateFlags.value.nodes).toBe(true)
})
it('should detect connection changes', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Cache initial state
graphManager.checkForChanges()
// Change connections
mockGraph.links = new Map([
[1, { id: 1 }],
[2, { id: 2 }]
]) as any
const hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true)
expect(graphManager.updateFlags.value.connections).toBe(true)
})
it('should handle node removal in callbacks', () => {
const originalOnNodeRemoved = vi.fn()
mockGraph.onNodeRemoved = originalOnNodeRemoved
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
const removedNode = { id: '2' } as LGraphNode
mockGraph.onNodeRemoved!(removedNode)
expect(originalOnNodeRemoved).toHaveBeenCalledWith(removedNode)
expect(onGraphChangedMock).toHaveBeenCalled()
})
it('should destroy properly', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.init()
graphManager.setupEventListeners()
graphManager.destroy()
expect(api.removeEventListener).toHaveBeenCalledWith(
'graphChanged',
expect.any(Function)
)
})
it('should clear cache', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Populate cache
graphManager.checkForChanges()
// Clear cache
graphManager.clearCache()
// Should detect changes again after clear
const hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true)
})
it('should handle null graph gracefully', () => {
const graphRef = ref(null as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
expect(() => graphManager.setupEventListeners()).not.toThrow()
expect(() => graphManager.cleanupEventListeners()).not.toThrow()
expect(graphManager.checkForChanges()).toBe(false)
})
it('should clean up removed nodes from cache', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Cache initial state
graphManager.checkForChanges()
// Remove a node
mockGraph._nodes = mockGraph._nodes.filter((n) => n.id !== '2')
const hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true)
expect(graphManager.updateFlags.value.bounds).toBe(true)
})
it('should throttle graph changed callback', () => {
const throttledFn = vi.fn()
vi.mocked(useThrottleFn).mockReturnValue(throttledFn)
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
// Trigger multiple changes rapidly
mockGraph.onNodeAdded!({ id: '3' } as LGraphNode)
mockGraph.onNodeAdded!({ id: '4' } as LGraphNode)
mockGraph.onNodeAdded!({ id: '5' } as LGraphNode)
// Should be throttled
expect(throttledFn).toHaveBeenCalledTimes(3)
})
})

View File

@@ -0,0 +1,328 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useMinimapInteraction } from '@/renderer/extensions/minimap/composables/useMinimapInteraction'
import type { MinimapCanvas } from '@/renderer/extensions/minimap/types'
describe('useMinimapInteraction', () => {
let mockContainer: HTMLDivElement
let mockCanvas: MinimapCanvas
let centerViewOnMock: (worldX: number, worldY: number) => void
beforeEach(() => {
vi.clearAllMocks()
mockContainer = {
getBoundingClientRect: vi.fn().mockReturnValue({
left: 100,
top: 50,
width: 250,
height: 200
})
} as any
mockCanvas = {
ds: {
scale: 1,
offset: [0, 0]
},
setDirty: vi.fn()
} as any
centerViewOnMock = vi.fn<(worldX: number, worldY: number) => void>()
})
it('should initialize with default values', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
expect(interaction.isDragging.value).toBe(false)
expect(interaction.containerRect.value).toEqual({
left: 0,
top: 0,
width: 250,
height: 200
})
})
it('should update container rect', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
interaction.updateContainerRect()
expect(mockContainer.getBoundingClientRect).toHaveBeenCalled()
expect(interaction.containerRect.value).toEqual({
left: 100,
top: 50,
width: 250,
height: 200
})
})
it('should handle pointer down and start dragging', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
const event = new PointerEvent('pointerdown', {
clientX: 150,
clientY: 100
})
interaction.handlePointerDown(event)
expect(interaction.isDragging.value).toBe(true)
expect(mockContainer.getBoundingClientRect).toHaveBeenCalled()
expect(centerViewOnMock).toHaveBeenCalled()
})
it('should handle pointer move when dragging', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
// Start dragging
interaction.handlePointerDown(
new PointerEvent('pointerdown', {
clientX: 150,
clientY: 100
})
)
// Move pointer
const moveEvent = new PointerEvent('pointermove', {
clientX: 200,
clientY: 150
})
interaction.handlePointerMove(moveEvent)
// Should calculate world coordinates and center view
expect(centerViewOnMock).toHaveBeenCalledTimes(2) // Once on down, once on move
// Calculate expected world coordinates
const x = 200 - 100 // clientX - containerLeft
const y = 150 - 50 // clientY - containerTop
const offsetX = (250 - 500 * 0.5) / 2 // (width - bounds.width * scale) / 2
const offsetY = (200 - 400 * 0.5) / 2 // (height - bounds.height * scale) / 2
const worldX = (x - offsetX) / 0.5 + 0 // (x - offsetX) / scale + bounds.minX
const worldY = (y - offsetY) / 0.5 + 0 // (y - offsetY) / scale + bounds.minY
expect(centerViewOnMock).toHaveBeenLastCalledWith(worldX, worldY)
})
it('should not move when not dragging', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
const moveEvent = new PointerEvent('pointermove', {
clientX: 200,
clientY: 150
})
interaction.handlePointerMove(moveEvent)
expect(centerViewOnMock).not.toHaveBeenCalled()
})
it('should handle pointer up to stop dragging', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
// Start dragging
interaction.handlePointerDown(
new PointerEvent('pointerdown', {
clientX: 150,
clientY: 100
})
)
expect(interaction.isDragging.value).toBe(true)
interaction.handlePointerUp()
expect(interaction.isDragging.value).toBe(false)
})
it('should handle wheel events for zooming', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100,
clientX: 200,
clientY: 150
})
wheelEvent.preventDefault = vi.fn()
interaction.handleWheel(wheelEvent)
// Should update canvas scale (zoom in)
expect(mockCanvas.ds.scale).toBeCloseTo(1.1)
expect(centerViewOnMock).toHaveBeenCalled()
})
it('should respect zoom limits', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
// Set scale close to minimum
mockCanvas.ds.scale = 0.11
const wheelEvent = new WheelEvent('wheel', {
deltaY: 100, // Zoom out
clientX: 200,
clientY: 150
})
wheelEvent.preventDefault = vi.fn()
interaction.handleWheel(wheelEvent)
// Should not go below minimum scale
expect(mockCanvas.ds.scale).toBe(0.11)
expect(centerViewOnMock).not.toHaveBeenCalled()
})
it('should handle null container gracefully', () => {
const containerRef = ref<HTMLDivElement | null>(null)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
// Should not throw
expect(() => interaction.updateContainerRect()).not.toThrow()
expect(() =>
interaction.handlePointerDown(new PointerEvent('pointerdown'))
).not.toThrow()
})
it('should handle null canvas gracefully', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(null as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
// Should not throw
expect(() =>
interaction.handlePointerMove(new PointerEvent('pointermove'))
).not.toThrow()
expect(() => interaction.handleWheel(new WheelEvent('wheel'))).not.toThrow()
expect(centerViewOnMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,266 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref, shallowRef } from 'vue'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { useMinimapRenderer } from '@/renderer/extensions/minimap/composables/useMinimapRenderer'
import { renderMinimapToCanvas } from '@/renderer/extensions/minimap/minimapCanvasRenderer'
import type { UpdateFlags } from '@/renderer/extensions/minimap/types'
vi.mock('@/renderer/extensions/minimap/minimapCanvasRenderer', () => ({
renderMinimapToCanvas: vi.fn()
}))
describe('useMinimapRenderer', () => {
let mockCanvas: HTMLCanvasElement
let mockContext: CanvasRenderingContext2D
let mockGraph: LGraph
beforeEach(() => {
vi.clearAllMocks()
mockContext = {
clearRect: vi.fn()
} as any
mockCanvas = {
getContext: vi.fn().mockReturnValue(mockContext)
} as any
mockGraph = {
_nodes: [{ id: '1', pos: [0, 0], size: [100, 100] }]
} as any
})
it('should initialize with full redraw needed', () => {
const canvasRef = shallowRef<HTMLCanvasElement | null>(mockCanvas)
const graphRef = ref(mockGraph as any)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
const scaleRef = ref(1)
const updateFlagsRef = ref<UpdateFlags>({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
const settings = {
nodeColors: ref(true),
showLinks: ref(true),
showGroups: ref(true),
renderBypass: ref(false),
renderError: ref(false)
}
const renderer = useMinimapRenderer(
canvasRef,
graphRef,
boundsRef,
scaleRef,
updateFlagsRef,
settings,
250,
200
)
expect(renderer.needsFullRedraw.value).toBe(true)
expect(renderer.needsBoundsUpdate.value).toBe(true)
})
it('should handle empty graph with fast path', () => {
const emptyGraph = { _nodes: [] } as any
const canvasRef = ref(mockCanvas)
const graphRef = ref(emptyGraph)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
const scaleRef = ref(1)
const updateFlagsRef = ref<UpdateFlags>({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
const settings = {
nodeColors: ref(true),
showLinks: ref(true),
showGroups: ref(true),
renderBypass: ref(false),
renderError: ref(false)
}
const renderer = useMinimapRenderer(
canvasRef,
graphRef,
boundsRef,
scaleRef,
updateFlagsRef,
settings,
250,
200
)
renderer.renderMinimap()
expect(mockContext.clearRect).toHaveBeenCalledWith(0, 0, 250, 200)
expect(vi.mocked(renderMinimapToCanvas)).not.toHaveBeenCalled()
})
it('should only render when redraw is needed', async () => {
const { renderMinimapToCanvas } =
await import('@/renderer/extensions/minimap/minimapCanvasRenderer')
const canvasRef = ref(mockCanvas)
const graphRef = ref(mockGraph as any)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
const scaleRef = ref(1)
const updateFlagsRef = ref<UpdateFlags>({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
const settings = {
nodeColors: ref(true),
showLinks: ref(true),
showGroups: ref(true),
renderBypass: ref(false),
renderError: ref(false)
}
const renderer = useMinimapRenderer(
canvasRef,
graphRef,
boundsRef,
scaleRef,
updateFlagsRef,
settings,
250,
200
)
// First render (needsFullRedraw is true by default)
renderer.renderMinimap()
expect(vi.mocked(renderMinimapToCanvas)).toHaveBeenCalledTimes(1)
// Second render without changes (should not render)
renderer.renderMinimap()
expect(vi.mocked(renderMinimapToCanvas)).toHaveBeenCalledTimes(1)
// Set update flag and render again
updateFlagsRef.value.nodes = true
renderer.renderMinimap()
expect(vi.mocked(renderMinimapToCanvas)).toHaveBeenCalledTimes(2)
})
it('should update minimap with bounds and viewport callbacks', () => {
const updateBounds = vi.fn()
const updateViewport = vi.fn()
const canvasRef = ref(mockCanvas)
const graphRef = ref(mockGraph as any)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
const scaleRef = ref(1)
const updateFlagsRef = ref<UpdateFlags>({
bounds: true,
nodes: false,
connections: false,
viewport: false
})
const settings = {
nodeColors: ref(true),
showLinks: ref(true),
showGroups: ref(true),
renderBypass: ref(false),
renderError: ref(false)
}
const renderer = useMinimapRenderer(
canvasRef,
graphRef,
boundsRef,
scaleRef,
updateFlagsRef,
settings,
250,
200
)
renderer.updateMinimap(updateBounds, updateViewport)
expect(updateBounds).toHaveBeenCalled()
expect(updateViewport).toHaveBeenCalled()
expect(updateFlagsRef.value.bounds).toBe(false)
expect(renderer.needsFullRedraw.value).toBe(false) // After rendering, needsFullRedraw is reset to false
expect(updateFlagsRef.value.viewport).toBe(false) // After updating viewport, this is reset to false
})
it('should force full redraw when requested', () => {
const canvasRef = ref(mockCanvas)
const graphRef = ref(mockGraph as any)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
const scaleRef = ref(1)
const updateFlagsRef = ref<UpdateFlags>({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
const settings = {
nodeColors: ref(true),
showLinks: ref(true),
showGroups: ref(true),
renderBypass: ref(false),
renderError: ref(false)
}
const renderer = useMinimapRenderer(
canvasRef,
graphRef,
boundsRef,
scaleRef,
updateFlagsRef,
settings,
250,
200
)
renderer.forceFullRedraw()
expect(renderer.needsFullRedraw.value).toBe(true)
expect(updateFlagsRef.value.bounds).toBe(true)
expect(updateFlagsRef.value.nodes).toBe(true)
expect(updateFlagsRef.value.connections).toBe(true)
expect(updateFlagsRef.value.viewport).toBe(true)
})
it('should handle null canvas gracefully', () => {
const canvasRef = shallowRef<HTMLCanvasElement | null>(null)
const graphRef = ref(mockGraph as any)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
const scaleRef = ref(1)
const updateFlagsRef = ref<UpdateFlags>({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
const settings = {
nodeColors: ref(true),
showLinks: ref(true),
showGroups: ref(true),
renderBypass: ref(false),
renderError: ref(false)
}
const renderer = useMinimapRenderer(
canvasRef,
graphRef,
boundsRef,
scaleRef,
updateFlagsRef,
settings,
250,
200
)
// Should not throw
expect(() => renderer.renderMinimap()).not.toThrow()
expect(mockCanvas.getContext).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,125 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useMinimapSettings } from '@/renderer/extensions/minimap/composables/useMinimapSettings'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
vi.mock('@/platform/settings/settingStore')
vi.mock('@/stores/workspace/colorPaletteStore')
describe('useMinimapSettings', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('should return all minimap settings as computed refs', () => {
const mockSettingStore = {
get: vi.fn((key: string) => {
const settings: Record<string, any> = {
'Comfy.Minimap.NodeColors': true,
'Comfy.Minimap.ShowLinks': false,
'Comfy.Minimap.ShowGroups': true,
'Comfy.Minimap.RenderBypassState': false,
'Comfy.Minimap.RenderErrorState': true
}
return settings[key]
})
}
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any)
vi.mocked(useColorPaletteStore).mockReturnValue({
completedActivePalette: { light_theme: false }
} as any)
const settings = useMinimapSettings()
expect(settings.nodeColors.value).toBe(true)
expect(settings.showLinks.value).toBe(false)
expect(settings.showGroups.value).toBe(true)
expect(settings.renderBypass.value).toBe(false)
expect(settings.renderError.value).toBe(true)
})
it('should generate container styles based on theme', () => {
const mockColorPaletteStore = {
completedActivePalette: { light_theme: false }
}
vi.mocked(useSettingStore).mockReturnValue({ get: vi.fn() } as any)
vi.mocked(useColorPaletteStore).mockReturnValue(
mockColorPaletteStore as any
)
const settings = useMinimapSettings()
const styles = settings.containerStyles.value
expect(styles.width).toBe('253px')
expect(styles.height).toBe('200px')
expect(styles.border).toBe('1px solid var(--interface-stroke)')
expect(styles.borderRadius).toBe('8px')
})
it('should generate light theme container styles', () => {
const mockColorPaletteStore = {
completedActivePalette: { light_theme: true }
}
vi.mocked(useSettingStore).mockReturnValue({ get: vi.fn() } as any)
vi.mocked(useColorPaletteStore).mockReturnValue(
mockColorPaletteStore as any
)
const settings = useMinimapSettings()
const styles = settings.containerStyles.value
expect(styles.width).toBe('253px')
expect(styles.height).toBe('200px')
expect(styles.border).toBe('1px solid var(--interface-stroke)')
expect(styles.borderRadius).toBe('8px')
})
it('should generate panel styles based on theme', () => {
const mockColorPaletteStore = {
completedActivePalette: { light_theme: false }
}
vi.mocked(useSettingStore).mockReturnValue({ get: vi.fn() } as any)
vi.mocked(useColorPaletteStore).mockReturnValue(
mockColorPaletteStore as any
)
const settings = useMinimapSettings()
const styles = settings.panelStyles.value
expect(styles.width).toBe('210px')
expect(styles.height).toBe('200px')
expect(styles.border).toBe('1px solid var(--interface-stroke)')
expect(styles.borderRadius).toBe('8px')
})
it('should create computed properties that call the store getter', () => {
const mockGet = vi.fn((key: string) => {
if (key === 'Comfy.Minimap.NodeColors') return true
if (key === 'Comfy.Minimap.ShowLinks') return false
return true
})
const mockSettingStore = { get: mockGet }
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any)
vi.mocked(useColorPaletteStore).mockReturnValue({
completedActivePalette: { light_theme: false }
} as any)
const settings = useMinimapSettings()
// Access the computed properties
expect(settings.nodeColors.value).toBe(true)
expect(settings.showLinks.value).toBe(false)
// Verify the store getter was called with the correct keys
expect(mockGet).toHaveBeenCalledWith('Comfy.Minimap.NodeColors')
expect(mockGet).toHaveBeenCalledWith('Comfy.Minimap.ShowLinks')
})
})

View File

@@ -0,0 +1,287 @@
import { useRafFn } from '@vueuse/core'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { useMinimapViewport } from '@/renderer/extensions/minimap/composables/useMinimapViewport'
import type { MinimapCanvas } from '@/renderer/extensions/minimap/types'
vi.mock('@vueuse/core')
vi.mock('@/renderer/core/spatial/boundsCalculator', () => ({
calculateNodeBounds: vi.fn(),
calculateMinimapScale: vi.fn(),
enforceMinimumBounds: vi.fn()
}))
describe('useMinimapViewport', () => {
let mockCanvas: MinimapCanvas
let mockGraph: LGraph
beforeEach(() => {
vi.clearAllMocks()
mockCanvas = {
canvas: {
clientWidth: 800,
clientHeight: 600,
width: 1600,
height: 1200
} as HTMLCanvasElement,
ds: {
scale: 1,
offset: [0, 0]
},
setDirty: vi.fn()
}
mockGraph = {
_nodes: [
{ pos: [100, 100], size: [150, 80] },
{ pos: [300, 200], size: [120, 60] }
]
} as any
vi.mocked(useRafFn, { partial: true }).mockReturnValue({
resume: vi.fn(),
pause: vi.fn()
})
})
it('should initialize with default bounds', () => {
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
expect(viewport.bounds.value).toEqual({
minX: 0,
minY: 0,
maxX: 0,
maxY: 0,
width: 0,
height: 0
})
expect(viewport.scale.value).toBe(1)
})
it('should calculate graph bounds from nodes', async () => {
const { calculateNodeBounds, enforceMinimumBounds } =
await import('@/renderer/core/spatial/boundsCalculator')
vi.mocked(calculateNodeBounds).mockReturnValue({
minX: 100,
minY: 100,
maxX: 420,
maxY: 260,
width: 320,
height: 160
})
vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds)
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.updateBounds()
expect(calculateNodeBounds).toHaveBeenCalledWith(mockGraph._nodes)
expect(enforceMinimumBounds).toHaveBeenCalled()
})
it('should handle empty graph', async () => {
const { calculateNodeBounds } =
await import('@/renderer/core/spatial/boundsCalculator')
vi.mocked(calculateNodeBounds).mockReturnValue(null)
const canvasRef = ref(mockCanvas as any)
const graphRef = ref({ _nodes: [] } as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.updateBounds()
expect(viewport.bounds.value).toEqual({
minX: 0,
minY: 0,
maxX: 100,
maxY: 100,
width: 100,
height: 100
})
})
it('should update canvas dimensions', () => {
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.updateCanvasDimensions()
expect(viewport.canvasDimensions.value).toEqual({
width: 800,
height: 600
})
})
it('should calculate viewport transform', async () => {
const { calculateNodeBounds, enforceMinimumBounds, calculateMinimapScale } =
await import('@/renderer/core/spatial/boundsCalculator')
// Mock the bounds calculation
vi.mocked(calculateNodeBounds).mockReturnValue({
minX: 0,
minY: 0,
maxX: 500,
maxY: 400,
width: 500,
height: 400
})
vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds)
vi.mocked(calculateMinimapScale).mockReturnValue(0.5)
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
// Set canvas transform
mockCanvas.ds.scale = 2
mockCanvas.ds.offset = [-100, -50]
// Update bounds and viewport
viewport.updateBounds()
viewport.updateCanvasDimensions()
viewport.updateViewport()
const transform = viewport.viewportTransform.value
// World coordinates
const worldX = -(-100) // -offset[0] = 100
const worldY = -(-50) // -offset[1] = 50
// Viewport size in world coordinates
const viewportWidth = 800 / 2 // canvasWidth / scale = 400
const viewportHeight = 600 / 2 // canvasHeight / scale = 300
// Center offsets
const centerOffsetX = (250 - 500 * 0.5) / 2 // (250 - 250) / 2 = 0
const centerOffsetY = (200 - 400 * 0.5) / 2 // (200 - 200) / 2 = 0
// Expected values based on implementation: (worldX - bounds.minX) * scale + centerOffsetX
expect(transform.x).toBeCloseTo((worldX - 0) * 0.5 + centerOffsetX) // (100 - 0) * 0.5 + 0 = 50
expect(transform.y).toBeCloseTo((worldY - 0) * 0.5 + centerOffsetY) // (50 - 0) * 0.5 + 0 = 25
expect(transform.width).toBeCloseTo(viewportWidth * 0.5) // 400 * 0.5 = 200
expect(transform.height).toBeCloseTo(viewportHeight * 0.5) // 300 * 0.5 = 150
})
it('should center view on world coordinates', () => {
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.updateCanvasDimensions()
mockCanvas.ds.scale = 2
viewport.centerViewOn(300, 200)
// Should update canvas offset to center on the given world coordinates
const expectedOffsetX = -(300 - 800 / 2 / 2) // -(worldX - viewportWidth/2)
const expectedOffsetY = -(200 - 600 / 2 / 2) // -(worldY - viewportHeight/2)
expect(mockCanvas.ds.offset[0]).toBe(expectedOffsetX)
expect(mockCanvas.ds.offset[1]).toBe(expectedOffsetY)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('should start and stop viewport sync', () => {
const startSyncMock = vi.fn()
const stopSyncMock = vi.fn()
vi.mocked(useRafFn, { partial: true }).mockReturnValue({
resume: startSyncMock,
pause: stopSyncMock
})
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.startViewportSync()
expect(startSyncMock).toHaveBeenCalled()
viewport.stopViewportSync()
expect(stopSyncMock).toHaveBeenCalled()
})
it('should handle null canvas gracefully', () => {
const canvasRef = ref(null as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
// Should not throw
expect(() => viewport.updateCanvasDimensions()).not.toThrow()
expect(() => viewport.updateViewport()).not.toThrow()
expect(() => viewport.centerViewOn(100, 100)).not.toThrow()
})
it('should calculate scale correctly', async () => {
const { calculateMinimapScale, calculateNodeBounds, enforceMinimumBounds } =
await import('@/renderer/core/spatial/boundsCalculator')
const testBounds = {
minX: 0,
minY: 0,
maxX: 500,
maxY: 400,
width: 500,
height: 400
}
vi.mocked(calculateNodeBounds).mockReturnValue(testBounds)
vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds)
vi.mocked(calculateMinimapScale).mockReturnValue(0.4)
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.updateBounds()
expect(calculateMinimapScale).toHaveBeenCalledWith(testBounds, 250, 200)
expect(viewport.scale.value).toBe(0.4)
})
it('should handle device pixel ratio', () => {
const originalDPR = window.devicePixelRatio
Object.defineProperty(window, 'devicePixelRatio', {
value: 2,
configurable: true
})
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.updateCanvasDimensions()
// Should use client dimensions or calculate from canvas dimensions / dpr
expect(viewport.canvasDimensions.value.width).toBe(800)
expect(viewport.canvasDimensions.value.height).toBe(600)
Object.defineProperty(window, 'devicePixelRatio', {
value: originalDPR,
configurable: true
})
})
})

View File

@@ -0,0 +1,175 @@
import { describe, expect, it, vi } from 'vitest'
import { computed } from 'vue'
import type { ComputedRef } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { NodeLayout } from '@/renderer/core/layout/types'
import { MinimapDataSourceFactory } from '@/renderer/extensions/minimap/data/MinimapDataSourceFactory'
// Mock layoutStore
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
getAllNodes: vi.fn()
}
}))
// Helper to create mock links that satisfy LGraph['links'] type
function createMockLinks(): LGraph['links'] {
const map = new Map<number, LLink>()
return Object.assign(map, {}) as LGraph['links']
}
describe('MinimapDataSource', () => {
describe('MinimapDataSourceFactory', () => {
it('should create LayoutStoreDataSource when LayoutStore has data', () => {
// Arrange
const mockNodes = new Map<string, NodeLayout>([
[
'node1',
{
id: 'node1',
position: { x: 0, y: 0 },
size: { width: 100, height: 50 },
zIndex: 0,
visible: true,
bounds: { x: 0, y: 0, width: 100, height: 50 }
}
]
])
// Create a computed ref that returns the map
const computedNodes: ComputedRef<ReadonlyMap<string, NodeLayout>> =
computed(() => mockNodes)
vi.mocked(layoutStore.getAllNodes).mockReturnValue(computedNodes)
const mockGraph: Pick<LGraph, '_nodes' | '_groups' | 'links'> = {
_nodes: [],
_groups: [],
links: createMockLinks()
}
// Act
const dataSource = MinimapDataSourceFactory.create(mockGraph as LGraph)
// Assert
expect(dataSource).toBeDefined()
expect(dataSource.hasData()).toBe(true)
expect(dataSource.getNodeCount()).toBe(1)
})
it('should create LiteGraphDataSource when LayoutStore is empty', () => {
// Arrange
const emptyMap = new Map<string, NodeLayout>()
const computedEmpty: ComputedRef<ReadonlyMap<string, NodeLayout>> =
computed(() => emptyMap)
vi.mocked(layoutStore.getAllNodes).mockReturnValue(computedEmpty)
const mockNode: Pick<
LGraphNode,
'id' | 'pos' | 'size' | 'bgcolor' | 'mode' | 'has_errors' | 'outputs'
> = {
id: 'node1' as NodeId,
pos: [0, 0],
size: [100, 50],
bgcolor: '#fff',
mode: 0,
has_errors: false,
outputs: []
}
const mockGraph: Pick<LGraph, '_nodes' | '_groups' | 'links'> = {
_nodes: [mockNode as LGraphNode],
_groups: [],
links: createMockLinks()
}
// Act
const dataSource = MinimapDataSourceFactory.create(mockGraph as LGraph)
// Assert
expect(dataSource).toBeDefined()
expect(dataSource.hasData()).toBe(true)
expect(dataSource.getNodeCount()).toBe(1)
const nodes = dataSource.getNodes()
expect(nodes).toHaveLength(1)
expect(nodes[0]).toMatchObject({
id: 'node1',
x: 0,
y: 0,
width: 100,
height: 50
})
})
it('should handle empty graph correctly', () => {
// Arrange
const emptyMap = new Map<string, NodeLayout>()
const computedEmpty: ComputedRef<ReadonlyMap<string, NodeLayout>> =
computed(() => emptyMap)
vi.mocked(layoutStore.getAllNodes).mockReturnValue(computedEmpty)
const mockGraph: Pick<LGraph, '_nodes' | '_groups' | 'links'> = {
_nodes: [],
_groups: [],
links: createMockLinks()
}
// Act
const dataSource = MinimapDataSourceFactory.create(mockGraph as LGraph)
// Assert
expect(dataSource.hasData()).toBe(false)
expect(dataSource.getNodeCount()).toBe(0)
expect(dataSource.getNodes()).toEqual([])
expect(dataSource.getLinks()).toEqual([])
expect(dataSource.getGroups()).toEqual([])
})
})
describe('Bounds calculation', () => {
it('should calculate correct bounds from nodes', () => {
// Arrange
const emptyMap = new Map<string, NodeLayout>()
const computedEmpty: ComputedRef<ReadonlyMap<string, NodeLayout>> =
computed(() => emptyMap)
vi.mocked(layoutStore.getAllNodes).mockReturnValue(computedEmpty)
const mockNode1: Pick<LGraphNode, 'id' | 'pos' | 'size' | 'outputs'> = {
id: 'node1' as NodeId,
pos: [0, 0],
size: [100, 50],
outputs: []
}
const mockNode2: Pick<LGraphNode, 'id' | 'pos' | 'size' | 'outputs'> = {
id: 'node2' as NodeId,
pos: [200, 100],
size: [150, 75],
outputs: []
}
const mockGraph: Pick<LGraph, '_nodes' | '_groups' | 'links'> = {
_nodes: [mockNode1 as LGraphNode, mockNode2 as LGraphNode],
_groups: [],
links: createMockLinks()
}
// Act
const dataSource = MinimapDataSourceFactory.create(mockGraph as LGraph)
const bounds = dataSource.getBounds()
// Assert
expect(bounds).toEqual({
minX: 0,
minY: 0,
maxX: 350,
maxY: 175,
width: 350,
height: 175
})
})
})
})

View File

@@ -0,0 +1,324 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { renderMinimapToCanvas } from '@/renderer/extensions/minimap/minimapCanvasRenderer'
import type { MinimapRenderContext } from '@/renderer/extensions/minimap/types'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
vi.mock('@/stores/workspace/colorPaletteStore')
vi.mock('@/utils/colorUtil', () => ({
adjustColor: vi.fn((color: string) => color + '_adjusted')
}))
describe('minimapCanvasRenderer', () => {
let mockCanvas: HTMLCanvasElement
let mockContext: CanvasRenderingContext2D
let mockGraph: LGraph
beforeEach(() => {
vi.clearAllMocks()
mockContext = {
clearRect: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
arc: vi.fn(),
fill: vi.fn(),
fillStyle: '',
strokeStyle: '',
lineWidth: 1
} as any
mockCanvas = {
getContext: vi.fn().mockReturnValue(mockContext)
} as any
mockGraph = {
_nodes: [
{
id: '1',
pos: [100, 100],
size: [150, 80],
bgcolor: '#FF0000',
mode: LGraphEventMode.ALWAYS,
has_errors: false,
outputs: []
},
{
id: '2',
pos: [300, 200],
size: [120, 60],
bgcolor: '#00FF00',
mode: LGraphEventMode.BYPASS,
has_errors: true,
outputs: []
}
] as unknown as LGraphNode[],
_groups: [],
links: {},
getNodeById: vi.fn()
} as any
vi.mocked(useColorPaletteStore).mockReturnValue({
completedActivePalette: { light_theme: false }
} as any)
})
it('should clear canvas and render nodes', () => {
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: true,
showLinks: false,
showGroups: false,
renderBypass: true,
renderError: true
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// Should clear the canvas first
expect(mockContext.clearRect).toHaveBeenCalledWith(0, 0, 250, 200)
// Should render nodes (batch by color)
expect(mockContext.fillRect).toHaveBeenCalled()
})
it('should handle empty graph', () => {
mockGraph._nodes = []
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: true,
showLinks: false,
showGroups: false,
renderBypass: false,
renderError: false
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
expect(mockContext.clearRect).toHaveBeenCalledWith(0, 0, 250, 200)
expect(mockContext.fillRect).not.toHaveBeenCalled()
})
it('should batch render nodes by color', () => {
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: true,
showLinks: false,
showGroups: false,
renderBypass: false,
renderError: false
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// Should set fill style for each color group
const fillStyleCalls = []
let currentStyle = ''
mockContext.fillStyle = ''
Object.defineProperty(mockContext, 'fillStyle', {
get: () => currentStyle,
set: (value) => {
currentStyle = value
fillStyleCalls.push(value)
}
})
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// Different colors for different nodes
expect(fillStyleCalls.length).toBeGreaterThan(0)
})
it('should render bypass nodes with special color', () => {
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: true,
showLinks: false,
showGroups: false,
renderBypass: true,
renderError: false
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// Node 2 is in bypass mode, should be rendered
expect(mockContext.fillRect).toHaveBeenCalled()
})
it('should render error outlines when enabled', () => {
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: true,
showLinks: false,
showGroups: false,
renderBypass: false,
renderError: true
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// Should set stroke style for errors
expect(mockContext.strokeStyle).toBe('#FF0000')
expect(mockContext.strokeRect).toHaveBeenCalled()
})
it('should render groups when enabled', () => {
mockGraph._groups = [
{
pos: [50, 50],
size: [400, 300],
color: '#0000FF'
}
] as any
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: true,
showLinks: false,
showGroups: true,
renderBypass: false,
renderError: false
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// Groups should be rendered before nodes
expect(mockContext.fillRect).toHaveBeenCalled()
})
it('should render connections when enabled', () => {
const targetNode = {
id: '2',
pos: [300, 200],
size: [120, 60]
}
mockGraph._nodes[0].outputs = [
{
links: [1]
}
] as any
// Create a hybrid Map/Object for links as LiteGraph expects
const linksMap = new Map([[1, { id: 1, target_id: 2 }]])
const links = Object.assign(linksMap, {
1: { id: 1, target_id: 2 }
})
mockGraph.links = links as any
mockGraph.getNodeById = vi.fn().mockReturnValue(targetNode)
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: false,
showLinks: true,
showGroups: false,
renderBypass: false,
renderError: false
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// Should draw connection lines
expect(mockContext.beginPath).toHaveBeenCalled()
expect(mockContext.moveTo).toHaveBeenCalled()
expect(mockContext.lineTo).toHaveBeenCalled()
expect(mockContext.stroke).toHaveBeenCalled()
// Should draw connection slots
expect(mockContext.arc).toHaveBeenCalled()
expect(mockContext.fill).toHaveBeenCalled()
})
it('should handle light theme colors', () => {
vi.mocked(useColorPaletteStore).mockReturnValue({
completedActivePalette: { light_theme: true }
} as any)
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: true,
showLinks: false,
showGroups: false,
renderBypass: false,
renderError: false
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// Color adjustment should be called for light theme
expect(adjustColor).toHaveBeenCalled()
})
it('should calculate correct offsets for centering', () => {
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 200, height: 100 },
scale: 0.5,
settings: {
nodeColors: false,
showLinks: false,
showGroups: false,
renderBypass: false,
renderError: false
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// With bounds 200x100 at scale 0.5 = 100x50
// Canvas is 250x200, so offset should be (250-100)/2 = 75, (200-50)/2 = 75
// This affects node positioning
expect(mockContext.fillRect).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,311 @@
import { createTestingPinia } from '@pinia/testing'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import ImagePreview from '@/renderer/extensions/vueNodes/components/ImagePreview.vue'
// Mock downloadFile to avoid DOM errors
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: vi.fn()
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
editOrMaskImage: 'Edit or mask image',
downloadImage: 'Download image',
removeImage: 'Remove image',
viewImageOfTotal: 'View image {index} of {total}',
imagePreview:
'Image preview - Use arrow keys to navigate between images',
errorLoadingImage: 'Error loading image',
failedToDownloadImage: 'Failed to download image',
calculatingDimensions: 'Calculating dimensions',
imageFailedToLoad: 'Image failed to load',
loading: 'Loading'
}
}
}
})
describe('ImagePreview', () => {
const defaultProps = {
imageUrls: [
'/api/view?filename=test1.png&type=output',
'/api/view?filename=test2.png&type=output'
]
}
const wrapperRegistry = new Set<VueWrapper>()
const mountImagePreview = (props = {}) => {
const wrapper = mount(ImagePreview, {
props: { ...defaultProps, ...props },
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn
}),
i18n
],
stubs: {
'i-lucide:venetian-mask': true,
'i-lucide:download': true,
'i-lucide:x': true,
'i-lucide:image-off': true,
Skeleton: true
}
}
})
wrapperRegistry.add(wrapper)
return wrapper
}
afterEach(() => {
wrapperRegistry.forEach((wrapper) => {
wrapper.unmount()
})
wrapperRegistry.clear()
})
it('renders image preview when imageUrls provided', () => {
const wrapper = mountImagePreview()
expect(wrapper.find('.image-preview').exists()).toBe(true)
expect(wrapper.find('img').exists()).toBe(true)
expect(wrapper.find('img').attributes('src')).toBe(
defaultProps.imageUrls[0]
)
})
it('does not render when no imageUrls provided', () => {
const wrapper = mountImagePreview({ imageUrls: [] })
expect(wrapper.find('.image-preview').exists()).toBe(false)
})
it('displays calculating dimensions text initially', () => {
const wrapper = mountImagePreview()
expect(wrapper.text()).toContain('Calculating dimensions')
})
it('shows navigation dots for multiple images', () => {
const wrapper = mountImagePreview()
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
expect(navigationDots).toHaveLength(2)
})
it('does not show navigation dots for single image', () => {
const wrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
expect(navigationDots).toHaveLength(0)
})
it('shows action buttons on hover', async () => {
const wrapper = mountImagePreview()
// Initially buttons should not be visible
expect(wrapper.find('.actions').exists()).toBe(false)
// Trigger hover on the image wrapper (the element with role="img" has the hover handlers)
const imageWrapper = wrapper.find('[role="img"]')
await imageWrapper.trigger('mouseenter')
await nextTick()
// Action buttons should now be visible
expect(wrapper.find('.actions').exists()).toBe(true)
// For multiple images: download and remove buttons (no mask button)
expect(wrapper.find('[aria-label="Download image"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="Remove image"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="Edit or mask image"]').exists()).toBe(
false
)
})
it('hides action buttons when not hovering', async () => {
const wrapper = mountImagePreview()
const imageWrapper = wrapper.find('[role="img"]')
// Trigger hover
await imageWrapper.trigger('mouseenter')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(true)
// Trigger mouse leave
await imageWrapper.trigger('mouseleave')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(false)
})
it('shows action buttons on focus', async () => {
const wrapper = mountImagePreview()
// Initially buttons should not be visible
expect(wrapper.find('.actions').exists()).toBe(false)
// Trigger focusin on the image wrapper (useFocusWithin listens to focusin/focusout)
const imageWrapper = wrapper.find('[role="img"]')
await imageWrapper.trigger('focusin')
await nextTick()
// Action buttons should now be visible
expect(wrapper.find('.actions').exists()).toBe(true)
})
it('hides action buttons on blur', async () => {
const wrapper = mountImagePreview()
const imageWrapper = wrapper.find('[role="img"]')
// Trigger focus
await imageWrapper.trigger('focusin')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(true)
// Trigger focusout
await imageWrapper.trigger('focusout')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(false)
})
it('shows mask/edit button only for single images', async () => {
// Multiple images - should not show mask button
const multipleImagesWrapper = mountImagePreview()
await multipleImagesWrapper.find('[role="img"]').trigger('mouseenter')
await nextTick()
const maskButtonMultiple = multipleImagesWrapper.find(
'[aria-label="Edit or mask image"]'
)
expect(maskButtonMultiple.exists()).toBe(false)
// Single image - should show mask button
const singleImageWrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
await singleImageWrapper.find('[role="img"]').trigger('mouseenter')
await nextTick()
const maskButtonSingle = singleImageWrapper.find(
'[aria-label="Edit or mask image"]'
)
expect(maskButtonSingle.exists()).toBe(true)
})
it('handles action button clicks', async () => {
const wrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
await wrapper.find('[role="img"]').trigger('mouseenter')
await nextTick()
// Test Edit/Mask button - just verify it can be clicked without errors
const editButton = wrapper.find('[aria-label="Edit or mask image"]')
expect(editButton.exists()).toBe(true)
await editButton.trigger('click')
// Test Remove button - just verify it can be clicked without errors
const removeButton = wrapper.find('[aria-label="Remove image"]')
expect(removeButton.exists()).toBe(true)
await removeButton.trigger('click')
})
it('handles download button click', async () => {
const wrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
await wrapper.find('[role="img"]').trigger('mouseenter')
await nextTick()
// Test Download button
const downloadButton = wrapper.find('[aria-label="Download image"]')
expect(downloadButton.exists()).toBe(true)
await downloadButton.trigger('click')
// Verify the mocked downloadFile was called
expect(downloadFile).toHaveBeenCalledWith(defaultProps.imageUrls[0])
})
it('switches images when navigation dots are clicked', async () => {
const wrapper = mountImagePreview()
// Initially shows first image
expect(wrapper.find('img').attributes('src')).toBe(
defaultProps.imageUrls[0]
)
// Click second navigation dot
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
await navigationDots[1].trigger('click')
await nextTick()
// Now should show second image
const imgElement = wrapper.find('img')
expect(imgElement.exists()).toBe(true)
expect(imgElement.attributes('src')).toBe(defaultProps.imageUrls[1])
})
it('applies correct classes to navigation dots based on current image', async () => {
const wrapper = mountImagePreview()
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
// First dot should be active (has bg-white class)
expect(navigationDots[0].classes()).toContain('bg-base-foreground')
expect(navigationDots[1].classes()).toContain('bg-base-foreground/50')
// Switch to second image
await navigationDots[1].trigger('click')
await nextTick()
// Second dot should now be active
expect(navigationDots[0].classes()).toContain('bg-base-foreground/50')
expect(navigationDots[1].classes()).toContain('bg-base-foreground')
})
it('loads image without errors', async () => {
const wrapper = mountImagePreview()
const img = wrapper.find('img')
expect(img.exists()).toBe(true)
// Just verify the image element is properly set up
expect(img.attributes('src')).toBe(defaultProps.imageUrls[0])
})
it('has proper accessibility attributes', () => {
const wrapper = mountImagePreview()
const img = wrapper.find('img')
expect(img.attributes('alt')).toBe('Node output 1')
})
it('updates alt text when switching images', async () => {
const wrapper = mountImagePreview()
// Initially first image
expect(wrapper.find('img').attributes('alt')).toBe('Node output 1')
// Switch to second image
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
await navigationDots[1].trigger('click')
await nextTick()
// Alt text should update
const imgElement = wrapper.find('img')
expect(imgElement.exists()).toBe(true)
expect(imgElement.attributes('alt')).toBe('Node output 2')
})
})

View File

@@ -0,0 +1,235 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, toValue } from 'vue'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
const mockData = vi.hoisted(() => ({
mockNodeIds: new Set<string>(),
mockExecuting: false
}))
vi.mock('@/renderer/core/layout/transform/useTransformState', () => {
return {
useTransformState: () => ({
screenToCanvas: vi.fn(),
canvasToScreen: vi.fn(),
camera: { z: 1 },
isNodeInViewport: vi.fn()
})
}
})
vi.mock('@/renderer/core/canvas/canvasStore', () => {
const getCanvas = vi.fn()
const useCanvasStore = () => ({
getCanvas,
selectedNodeIds: computed(() => mockData.mockNodeIds)
})
return {
useCanvasStore
}
})
vi.mock(
'@/renderer/extensions/vueNodes/composables/useNodeEventHandlers',
() => {
const handleNodeSelect = vi.fn()
return { useNodeEventHandlers: () => ({ handleNodeSelect }) }
}
)
vi.mock(
'@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking',
() => ({
useVueElementTracking: vi.fn()
})
)
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
toastErrorHandler: vi.fn()
})
}))
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({
useNodeLayout: () => ({
position: { x: 100, y: 50 },
size: computed(() => ({ width: 200, height: 100 })),
zIndex: 0,
startDrag: vi.fn(),
handleDrag: vi.fn(),
endDrag: vi.fn(),
moveTo: vi.fn()
})
}))
vi.mock(
'@/renderer/extensions/vueNodes/execution/useNodeExecutionState',
() => ({
useNodeExecutionState: vi.fn(() => ({
executing: computed(() => mockData.mockExecuting),
progress: computed(() => undefined),
progressPercentage: computed(() => undefined),
progressState: computed(() => undefined as any),
executionState: computed(() => 'idle' as const)
}))
})
)
vi.mock('@/renderer/extensions/vueNodes/preview/useNodePreviewState', () => ({
useNodePreviewState: vi.fn(() => ({
latestPreviewUrl: computed(() => ''),
shouldShowPreviewImg: computed(() => false)
}))
}))
vi.mock(
'@/renderer/extensions/vueNodes/interactions/resize/useNodeResize',
() => ({
useNodeResize: vi.fn(() => ({
startResize: vi.fn(),
isResizing: computed(() => false)
}))
})
)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
'Node Render Error': 'Node Render Error'
}
}
})
function mountLGraphNode(props: ComponentProps<typeof LGraphNode>) {
return mount(LGraphNode, {
props,
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn
}),
i18n
],
stubs: {
NodeHeader: true,
NodeSlots: true,
NodeWidgets: true,
NodeContent: true,
SlotConnectionDot: true
}
}
})
}
const mockNodeData: VueNodeData = {
id: 'test-node-123',
title: 'Test Node',
type: 'TestNode',
mode: 0,
flags: {},
inputs: [],
outputs: [],
widgets: [],
selected: false,
executing: false
}
describe('LGraphNode', () => {
beforeEach(() => {
vi.resetAllMocks()
mockData.mockNodeIds = new Set()
mockData.mockExecuting = false
})
it('should call resize tracking composable with node ID', () => {
mountLGraphNode({ nodeData: mockNodeData })
expect(useVueElementTracking).toHaveBeenCalledWith(
expect.any(Function),
'node'
)
const idArg = vi.mocked(useVueElementTracking).mock.calls[0]?.[0]
const id = toValue(idArg)
expect(id).toEqual('test-node-123')
})
it('should render with data-node-id attribute', () => {
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
expect(wrapper.attributes('data-node-id')).toBe('test-node-123')
})
it('should render node title', () => {
// Don't stub NodeHeader for this test so we can see the title
const wrapper = mount(LGraphNode, {
props: { nodeData: mockNodeData },
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn
}),
i18n
],
stubs: {
NodeSlots: true,
NodeWidgets: true,
NodeContent: true,
SlotConnectionDot: true
}
}
})
expect(wrapper.text()).toContain('Test Node')
})
it('should apply selected styling when selected prop is true', () => {
mockData.mockNodeIds = new Set(['test-node-123'])
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
expect(wrapper.classes()).toContain('outline-2')
expect(wrapper.classes()).toContain('outline-node-component-outline')
})
it('should render progress indicator when executing prop is true', () => {
mockData.mockExecuting = true
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
expect(wrapper.classes()).toContain('outline-node-stroke-executing')
})
it('should initialize height CSS vars for collapsed nodes', () => {
const wrapper = mountLGraphNode({
nodeData: {
...mockNodeData,
flags: { collapsed: true }
}
})
expect(wrapper.element.style.getPropertyValue('--node-height')).toBe('')
expect(wrapper.element.style.getPropertyValue('--node-height-x')).toBe(
'100px'
)
})
it('should initialize height CSS vars for expanded nodes', () => {
const wrapper = mountLGraphNode({
nodeData: {
...mockNodeData,
flags: { collapsed: false }
}
})
expect(wrapper.element.style.getPropertyValue('--node-height')).toBe(
'100px'
)
expect(wrapper.element.style.getPropertyValue('--node-height-x')).toBe('')
})
})

View File

@@ -0,0 +1,134 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import LivePreview from '@/renderer/extensions/vueNodes/components/LivePreview.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
liveSamplingPreview: 'Live sampling preview',
imageFailedToLoad: 'Image failed to load',
errorLoadingImage: 'Error loading image',
calculatingDimensions: 'Calculating dimensions'
}
}
}
})
describe('LivePreview', () => {
const defaultProps = {
imageUrl: '/api/view?filename=test_sample.png&type=temp'
}
const mountLivePreview = (props = {}) => {
return mount(LivePreview, {
props: { ...defaultProps, ...props },
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn
}),
i18n
],
stubs: {
'i-lucide:image-off': true
}
}
})
}
it('renders preview when imageUrl provided', () => {
const wrapper = mountLivePreview()
expect(wrapper.find('img').exists()).toBe(true)
expect(wrapper.find('img').attributes('src')).toBe(defaultProps.imageUrl)
})
it('does not render when no imageUrl provided', () => {
const wrapper = mountLivePreview({ imageUrl: null })
expect(wrapper.find('img').exists()).toBe(false)
expect(wrapper.text()).toBe('')
})
it('displays calculating dimensions text initially', () => {
const wrapper = mountLivePreview()
expect(wrapper.text()).toContain('Calculating dimensions')
})
it('has proper accessibility attributes', () => {
const wrapper = mountLivePreview()
const img = wrapper.find('img')
expect(img.attributes('alt')).toBe('Live sampling preview')
})
it('handles image load event', async () => {
const wrapper = mountLivePreview()
const img = wrapper.find('img')
// Mock the naturalWidth and naturalHeight properties on the img element
Object.defineProperty(img.element, 'naturalWidth', {
writable: false,
value: 512
})
Object.defineProperty(img.element, 'naturalHeight', {
writable: false,
value: 512
})
// Trigger the load event
await img.trigger('load')
expect(wrapper.text()).toContain('512 x 512')
})
it('handles image error state', async () => {
const wrapper = mountLivePreview()
const img = wrapper.find('img')
// Trigger the error event
await img.trigger('error')
// Check that the image is hidden and error content is shown
expect(wrapper.find('img').exists()).toBe(false)
expect(wrapper.text()).toContain('Image failed to load')
})
it('resets state when imageUrl changes', async () => {
const wrapper = mountLivePreview()
const img = wrapper.find('img')
// Set error state via event
await img.trigger('error')
expect(wrapper.text()).toContain('Error loading image')
// Change imageUrl prop
await wrapper.setProps({ imageUrl: '/new-image.png' })
await nextTick()
// State should be reset - dimensions text should show calculating
expect(wrapper.text()).toContain('Calculating dimensions')
expect(wrapper.text()).not.toContain('Error loading image')
})
it('shows error state when image fails to load', async () => {
const wrapper = mountLivePreview()
const img = wrapper.find('img')
// Trigger error event
await img.trigger('error')
// Should show error state instead of image
expect(wrapper.find('img').exists()).toBe(false)
expect(wrapper.text()).toContain('Image failed to load')
expect(wrapper.text()).toContain('Error loading image')
})
})

View File

@@ -0,0 +1,208 @@
/**
* Tests for NodeHeader subgraph functionality
*/
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
LGraph,
LGraphNode,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import NodeHeader from '@/renderer/extensions/vueNodes/components/NodeHeader.vue'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
const mockApp: { rootGraph?: Partial<LGraph> } = vi.hoisted(() => ({}))
// Mock dependencies
vi.mock('@/scripts/app', () => ({
app: mockApp
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByLocatorId: vi.fn(),
getLocatorIdFromNodeData: vi.fn((nodeData) =>
nodeData.subgraphId
? `${nodeData.subgraphId}:${String(nodeData.id)}`
: String(nodeData.id)
)
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
toastErrorHandler: vi.fn()
})
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: vi.fn((key) => key)
}),
createI18n: vi.fn(() => ({
global: {
t: vi.fn((key) => key)
}
}))
}))
vi.mock('@/i18n', () => ({
st: vi.fn((key) => key),
t: vi.fn((key) => key),
i18n: {
global: {
t: vi.fn((key) => key)
}
}
}))
describe('NodeHeader - Subgraph Functionality', () => {
// Helper to setup common mocks
const setupMocks = async (isSubgraph = true, hasGraph = true) => {
if (hasGraph) mockApp.rootGraph = {}
else mockApp.rootGraph = undefined
vi.mocked(getNodeByLocatorId).mockReturnValue({
isSubgraphNode: (): this is SubgraphNode => isSubgraph
} as LGraphNode)
}
beforeEach(() => {
vi.clearAllMocks()
})
const createMockNodeData = (
id: string,
subgraphId?: string
): VueNodeData => ({
id,
title: 'Test Node',
type: 'TestNode',
mode: 0,
selected: false,
executing: false,
subgraphId,
widgets: [],
inputs: [],
outputs: [],
hasErrors: false,
flags: {}
})
const createWrapper = (props = {}) => {
return mount(NodeHeader, {
props,
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })],
mocks: {
$t: vi.fn((key: string) => key),
$primevue: { config: {} }
}
}
})
}
it('should show subgraph button for subgraph nodes', async () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
})
await wrapper.vm.$nextTick()
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
expect(subgraphButton.exists()).toBe(true)
})
it('should not show subgraph button for regular nodes', async () => {
await setupMocks(false) // isSubgraph = false
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
})
await wrapper.vm.$nextTick()
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
expect(subgraphButton.exists()).toBe(false)
})
it('should emit enter-subgraph event when button is clicked', async () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
})
await wrapper.vm.$nextTick()
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
await subgraphButton.trigger('click')
expect(wrapper.emitted('enter-subgraph')).toBeTruthy()
expect(wrapper.emitted('enter-subgraph')).toHaveLength(1)
})
it('should handle subgraph context correctly', async () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1', 'subgraph-id'),
readonly: false
})
await wrapper.vm.$nextTick()
// Should call getNodeByLocatorId with correct locator ID
expect(vi.mocked(getNodeByLocatorId)).toHaveBeenCalledWith(
expect.anything(),
'subgraph-id:test-node-1'
)
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
expect(subgraphButton.exists()).toBe(true)
})
it('should handle missing graph gracefully', async () => {
await setupMocks(true, false) // isSubgraph = true, hasGraph = false
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
})
await wrapper.vm.$nextTick()
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
expect(subgraphButton.exists()).toBe(false)
})
it('should prevent event propagation on double click', async () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
})
await wrapper.vm.$nextTick()
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
// Mock event object
const mockEvent = {
stopPropagation: vi.fn()
}
// Trigger dblclick event
await subgraphButton.trigger('dblclick', mockEvent)
// Should prevent propagation (handled by @dblclick.stop directive)
// This is tested by ensuring the component doesn't error and renders correctly
expect(subgraphButton.exists()).toBe(true)
})
})

View File

@@ -0,0 +1,123 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import type {
SafeWidgetData,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
describe('NodeWidgets', () => {
const createMockWidget = (
overrides: Partial<SafeWidgetData> = {}
): SafeWidgetData => ({
name: 'test_widget',
type: 'combo',
value: 'test_value',
options: {
values: ['option1', 'option2']
},
callback: undefined,
spec: undefined,
label: undefined,
isDOMWidget: false,
slotMetadata: undefined,
...overrides
})
const createMockNodeData = (
nodeType: string = 'TestNode',
widgets: SafeWidgetData[] = []
): VueNodeData => ({
id: '1',
type: nodeType,
widgets,
title: 'Test Node',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
})
const mountComponent = (nodeData?: VueNodeData) => {
return mount(NodeWidgets, {
props: {
nodeData
},
global: {
plugins: [createTestingPinia()],
stubs: {
// Stub InputSlot to avoid complex slot registration dependencies
InputSlot: true
},
mocks: {
$t: (key: string) => key
}
}
})
}
describe('node-type prop passing', () => {
it('passes node type to widget components', () => {
const widget = createMockWidget()
const nodeData = createMockNodeData('CheckpointLoaderSimple', [widget])
const wrapper = mountComponent(nodeData)
// Find the dynamically rendered widget component
const widgetComponent = wrapper.find('.lg-node-widget')
expect(widgetComponent.exists()).toBe(true)
// Verify node-type prop is passed
const component = widgetComponent.findComponent({ name: 'WidgetSelect' })
if (component.exists()) {
expect(component.props('nodeType')).toBe('CheckpointLoaderSimple')
}
})
it('passes empty string when nodeData is undefined', () => {
const wrapper = mountComponent(undefined)
// No widgets should be rendered
const widgetComponents = wrapper.findAll('.lg-node-widget')
expect(widgetComponents).toHaveLength(0)
})
it('passes empty string when nodeData.type is undefined', () => {
const widget = createMockWidget()
const nodeData = createMockNodeData('', [widget])
const wrapper = mountComponent(nodeData)
const widgetComponent = wrapper.find('.lg-node-widget')
if (widgetComponent.exists()) {
const component = widgetComponent.findComponent({
name: 'WidgetSelect'
})
if (component.exists()) {
expect(component.props('nodeType')).toBe('')
}
}
})
it.for(['CheckpointLoaderSimple', 'LoraLoader', 'VAELoader', 'KSampler'])(
'passes correct node type: %s',
(nodeType) => {
const widget = createMockWidget()
const nodeData = createMockNodeData(nodeType, [widget])
const wrapper = mountComponent(nodeData)
const widgetComponent = wrapper.find('.lg-node-widget')
expect(widgetComponent.exists()).toBe(true)
const component = widgetComponent.findComponent({
name: 'WidgetSelect'
})
if (component.exists()) {
expect(component.props('nodeType')).toBe(nodeType)
}
}
)
})
})

View File

@@ -0,0 +1,303 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, shallowRef } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import type {
LGraph,
LGraphCanvas,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
const canvasSelectedItems = vi.hoisted(() => [] as Array<{ id?: string }>)
vi.mock('@/renderer/core/canvas/canvasStore', () => {
const canvas: Partial<LGraphCanvas> = {
select: vi.fn(),
deselect: vi.fn(),
deselectAll: vi.fn()
}
const updateSelectedItems = vi.fn()
const canvasStoreInstance = {
canvas: canvas as LGraphCanvas,
updateSelectedItems,
selectedItems: canvasSelectedItems
}
return {
useCanvasStore: vi.fn(() => canvasStoreInstance)
}
})
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
useCanvasInteractions: vi.fn(() => ({
shouldHandleNodePointerEvents: computed(() => true) // Default to allowing pointer events
}))
}))
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => {
const setSource = vi.fn()
const bringNodeToFront = vi.fn()
return {
useLayoutMutations: vi.fn(() => ({
setSource,
bringNodeToFront
}))
}
})
vi.mock('@/composables/graph/useGraphNodeManager', () => {
const mockNode = {
id: 'node-1',
selected: false,
flags: { pinned: false }
}
const nodeManager = shallowRef({
getNode: vi.fn(() => mockNode as Partial<LGraphNode> as LGraphNode)
} as Partial<GraphNodeManager> as GraphNodeManager)
return {
useGraphNodeManager: vi.fn(() => nodeManager)
}
})
vi.mock('@/composables/graph/useVueNodeLifecycle', () => {
const nodeManager = useGraphNodeManager(undefined as unknown as LGraph)
return {
useVueNodeLifecycle: vi.fn(() => ({
nodeManager
}))
}
})
describe('useNodeEventHandlers', () => {
const { nodeManager: mockNodeManager } = useVueNodeLifecycle()
const mockNode = mockNodeManager.value!.getNode('fake_id')
const mockLayoutMutations = useLayoutMutations()
const testNodeId = 'node-1'
beforeEach(async () => {
vi.resetAllMocks()
canvasSelectedItems.length = 0
})
describe('handleNodeSelect', () => {
it('should select single node on regular click', () => {
const { handleNodeSelect } = useNodeEventHandlers()
const { canvas, updateSelectedItems } = useCanvasStore()
const event = new PointerEvent('pointerdown', {
bubbles: true,
ctrlKey: false,
metaKey: false
})
handleNodeSelect(event, testNodeId)
expect(canvas?.deselectAll).toHaveBeenCalledOnce()
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
expect(updateSelectedItems).toHaveBeenCalledOnce()
})
it('on pointer down with ctrl+click: selects node immediately', () => {
const { handleNodeSelect } = useNodeEventHandlers()
const { canvas } = useCanvasStore()
mockNode!.selected = false
const ctrlClickEvent = new PointerEvent('pointerdown', {
bubbles: true,
ctrlKey: true,
metaKey: false
})
handleNodeSelect(ctrlClickEvent, testNodeId)
// On pointer down with multi-select: bring to front
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
'node-1'
)
// Selection happens immediately so dragging includes this node
expect(canvas?.deselectAll).not.toHaveBeenCalled()
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
expect(canvas?.deselect).not.toHaveBeenCalled()
})
it('on pointer down with ctrl+click of selected node: brings node to front only', () => {
const { handleNodeSelect } = useNodeEventHandlers()
const { canvas } = useCanvasStore()
mockNode!.selected = true
mockNode!.flags.pinned = false
const ctrlClickEvent = new PointerEvent('pointerdown', {
bubbles: true,
ctrlKey: true,
metaKey: false
})
handleNodeSelect(ctrlClickEvent, testNodeId)
// On pointer down: bring to front
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
'node-1'
)
// But don't deselect yet (deferred to pointer up)
expect(canvas?.deselect).not.toHaveBeenCalled()
expect(canvas?.select).not.toHaveBeenCalled()
})
it('on pointer down with meta key (Cmd): selects node immediately', () => {
const { handleNodeSelect } = useNodeEventHandlers()
const { canvas } = useCanvasStore()
mockNode!.selected = false
mockNode!.flags.pinned = false
const metaClickEvent = new PointerEvent('pointerdown', {
bubbles: true,
ctrlKey: false,
metaKey: true
})
handleNodeSelect(metaClickEvent, testNodeId)
// On pointer down with meta key: bring to front
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
'node-1'
)
// Selection happens immediately
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
expect(canvas?.deselectAll).not.toHaveBeenCalled()
expect(canvas?.deselect).not.toHaveBeenCalled()
})
it('on pointer down with shift key: selects node immediately', () => {
const { handleNodeSelect } = useNodeEventHandlers()
const { canvas } = useCanvasStore()
mockNode!.selected = false
mockNode!.flags.pinned = false
const shiftClickEvent = new PointerEvent('pointerdown', {
bubbles: true,
shiftKey: true
})
handleNodeSelect(shiftClickEvent, testNodeId)
// On pointer down with shift: bring to front
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
'node-1'
)
// Selection happens immediately for shift-click as well
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
expect(canvas?.deselectAll).not.toHaveBeenCalled()
expect(canvas?.deselect).not.toHaveBeenCalled()
})
it('keeps existing multi-selection when dragging selected node without modifiers', () => {
const { handleNodeSelect } = useNodeEventHandlers()
const { canvas } = useCanvasStore()
mockNode!.selected = true
canvasSelectedItems.push({ id: 'node-1' }, { id: 'node-2' })
const event = new PointerEvent('pointerdown', {
bubbles: true,
ctrlKey: false,
metaKey: false
})
handleNodeSelect(event, testNodeId)
expect(canvas?.deselectAll).not.toHaveBeenCalled()
expect(canvas?.select).not.toHaveBeenCalled()
})
it('should bring node to front when not pinned', () => {
const { handleNodeSelect } = useNodeEventHandlers()
mockNode!.flags.pinned = false
const event = new PointerEvent('pointerdown')
handleNodeSelect(event, testNodeId)
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
'node-1'
)
})
it('should not bring pinned node to front', () => {
const { handleNodeSelect } = useNodeEventHandlers()
mockNode!.flags.pinned = true
const event = new PointerEvent('pointerdown')
handleNodeSelect(event, testNodeId)
expect(mockLayoutMutations.bringNodeToFront).not.toHaveBeenCalled()
})
})
describe('toggleNodeSelectionAfterPointerUp', () => {
it('on pointer up with multi-select: deselects node that was selected at pointer down', () => {
const { toggleNodeSelectionAfterPointerUp } = useNodeEventHandlers()
const { canvas, updateSelectedItems } = useCanvasStore()
mockNode!.selected = true
toggleNodeSelectionAfterPointerUp('node-1', true)
expect(canvas?.deselect).toHaveBeenCalledWith(mockNode)
expect(updateSelectedItems).toHaveBeenCalledOnce()
})
it('on pointer up with multi-select and node not previously selected: no-op', () => {
const { toggleNodeSelectionAfterPointerUp } = useNodeEventHandlers()
const { canvas, updateSelectedItems } = useCanvasStore()
mockNode!.selected = true
toggleNodeSelectionAfterPointerUp('node-1', true)
expect(canvas?.select).not.toHaveBeenCalled()
expect(updateSelectedItems).toHaveBeenCalled()
})
it('on pointer up without multi-select: collapses multi-selection to clicked node', () => {
const { toggleNodeSelectionAfterPointerUp } = useNodeEventHandlers()
const { canvas, updateSelectedItems } = useCanvasStore()
mockNode!.selected = true
canvasSelectedItems.push({ id: 'node-1' }, { id: 'node-2' })
toggleNodeSelectionAfterPointerUp('node-1', false)
expect(canvas?.deselectAll).toHaveBeenCalledOnce()
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
expect(updateSelectedItems).toHaveBeenCalledOnce()
})
it('on pointer up without multi-select: keeps single selection intact', () => {
const { toggleNodeSelectionAfterPointerUp } = useNodeEventHandlers()
const { canvas, updateSelectedItems } = useCanvasStore()
mockNode!.selected = true
canvasSelectedItems.push({ id: 'node-1' })
toggleNodeSelectionAfterPointerUp('node-1', false)
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
expect(updateSelectedItems).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
// Mock the layout mutations module
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => ({
useLayoutMutations: vi.fn()
}))
const mockedUseLayoutMutations = vi.mocked(useLayoutMutations)
describe('useNodeZIndex', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('bringNodeToFront', () => {
it('should bring node to front with default source', () => {
const mockSetSource = vi.fn()
const mockBringNodeToFront = vi.fn()
mockedUseLayoutMutations.mockReturnValue({
setSource: mockSetSource,
bringNodeToFront: mockBringNodeToFront
} as Partial<ReturnType<typeof useLayoutMutations>> as ReturnType<
typeof useLayoutMutations
>)
const { bringNodeToFront } = useNodeZIndex()
bringNodeToFront('node1')
expect(mockSetSource).toHaveBeenCalledWith(LayoutSource.Vue)
expect(mockBringNodeToFront).toHaveBeenCalledWith('node1')
})
it('should bring node to front with custom source', () => {
const mockSetSource = vi.fn()
const mockBringNodeToFront = vi.fn()
mockedUseLayoutMutations.mockReturnValue({
setSource: mockSetSource,
bringNodeToFront: mockBringNodeToFront
} as Partial<ReturnType<typeof useLayoutMutations>> as ReturnType<
typeof useLayoutMutations
>)
const { bringNodeToFront } = useNodeZIndex()
bringNodeToFront('node2', LayoutSource.Canvas)
expect(mockSetSource).toHaveBeenCalledWith(LayoutSource.Canvas)
expect(mockBringNodeToFront).toHaveBeenCalledWith('node2')
})
it('should use custom layout source from options', () => {
const mockSetSource = vi.fn()
const mockBringNodeToFront = vi.fn()
mockedUseLayoutMutations.mockReturnValue({
setSource: mockSetSource,
bringNodeToFront: mockBringNodeToFront
} as Partial<ReturnType<typeof useLayoutMutations>> as ReturnType<
typeof useLayoutMutations
>)
const { bringNodeToFront } = useNodeZIndex({
layoutSource: LayoutSource.External
})
bringNodeToFront('node3')
expect(mockSetSource).toHaveBeenCalledWith(LayoutSource.External)
expect(mockBringNodeToFront).toHaveBeenCalledWith('node3')
})
it('should override layout source with explicit source parameter', () => {
const mockSetSource = vi.fn()
const mockBringNodeToFront = vi.fn()
mockedUseLayoutMutations.mockReturnValue({
setSource: mockSetSource,
bringNodeToFront: mockBringNodeToFront
} as Partial<ReturnType<typeof useLayoutMutations>> as ReturnType<
typeof useLayoutMutations
>)
const { bringNodeToFront } = useNodeZIndex({
layoutSource: LayoutSource.External
})
bringNodeToFront('node4', LayoutSource.Canvas)
expect(mockSetSource).toHaveBeenCalledWith(LayoutSource.Canvas)
expect(mockBringNodeToFront).toHaveBeenCalledWith('node4')
})
})
})

View File

@@ -0,0 +1,89 @@
import { createTestingPinia } from '@pinia/testing'
import { flushPromises, mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetSelect from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue'
// Mock modules
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
isAssetBrowserEligible: vi.fn(() => true)
}
}))
const mockSettingStoreGet = vi.fn()
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: mockSettingStoreGet
}))
}))
// Import after mocks are defined
import { assetService } from '@/platform/assets/services/assetService'
const mockAssetServiceEligible = vi.mocked(assetService.isAssetBrowserEligible)
describe('WidgetSelect asset mode', () => {
const createWidget = (): SimplifiedWidget<string | undefined> => ({
name: 'ckpt_name',
type: 'combo',
value: undefined,
options: {
values: []
}
})
beforeEach(() => {
vi.clearAllMocks()
mockAssetServiceEligible.mockReturnValue(true)
mockSettingStoreGet.mockReturnValue(true) // Default to true for UseAssetAPI
})
// Helper to mount with common setup
const mountWidget = () => {
return mount(WidgetSelect, {
props: {
widget: createWidget(),
modelValue: undefined,
nodeType: 'CheckpointLoaderSimple'
},
global: {
plugins: [PrimeVue, createTestingPinia()]
}
})
}
it('uses dropdown when isCloud && UseAssetAPI && isEligible', async () => {
const wrapper = mountWidget()
await flushPromises()
expect(
wrapper.findComponent({ name: 'WidgetSelectDropdown' }).exists()
).toBe(true)
})
it('uses default widget when UseAssetAPI setting is false', () => {
mockSettingStoreGet.mockReturnValue(false)
const wrapper = mountWidget()
expect(
wrapper.findComponent({ name: 'WidgetSelectDefault' }).exists()
).toBe(true)
})
it('uses default widget when node is not eligible', () => {
mockAssetServiceEligible.mockReturnValue(false)
const wrapper = mountWidget()
expect(
wrapper.findComponent({ name: 'WidgetSelectDefault' }).exists()
).toBe(true)
})
})

View File

@@ -0,0 +1,341 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Select from 'primevue/select'
import type { SelectProps } from 'primevue/select'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetSelect from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue'
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
// Mock state for distribution and settings
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
const mockSettingStoreGet = vi.hoisted(() => vi.fn(() => false))
const mockIsAssetBrowserEligible = vi.hoisted(() => vi.fn(() => false))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockDistributionState.isCloud
}
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: mockSettingStoreGet
}))
}))
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
isAssetBrowserEligible: mockIsAssetBrowserEligible
}
}))
describe('WidgetSelect Value Binding', () => {
beforeEach(() => {
// Reset all mocks before each test
mockDistributionState.isCloud = false
mockSettingStoreGet.mockReturnValue(false)
mockIsAssetBrowserEligible.mockReturnValue(false)
vi.clearAllMocks()
})
const createMockWidget = (
value: string = 'option1',
options: Partial<
SelectProps & { values?: string[]; return_index?: boolean }
> = {},
callback?: (value: string | undefined) => void,
spec?: ComboInputSpec
): SimplifiedWidget<string | undefined> => ({
name: 'test_select',
type: 'combo',
value,
options: {
values: ['option1', 'option2', 'option3'],
...options
},
callback,
spec
})
const mountComponent = (
widget: SimplifiedWidget<string | undefined>,
modelValue: string | undefined,
readonly = false
) => {
return mount(WidgetSelect, {
props: {
widget,
modelValue,
readonly
},
global: {
plugins: [PrimeVue, createTestingPinia()],
components: { Select }
}
})
}
const setSelectValueAndEmit = async (
wrapper: ReturnType<typeof mount>,
value: string
) => {
const select = wrapper.findComponent({ name: 'Select' })
await select.setValue(value)
return wrapper.emitted('update:modelValue')
}
describe('Vue Event Emission', () => {
it('emits Vue event when selection changes', async () => {
const widget = createMockWidget('option1')
const wrapper = mountComponent(widget, 'option1')
const emitted = await setSelectValueAndEmit(wrapper, 'option2')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('option2')
})
it('emits string value for different options', async () => {
const widget = createMockWidget('option1')
const wrapper = mountComponent(widget, 'option1')
const emitted = await setSelectValueAndEmit(wrapper, 'option3')
expect(emitted).toBeDefined()
// Should emit the string value
expect(emitted![0]).toContain('option3')
})
it('handles custom option values', async () => {
const customOptions = ['custom_a', 'custom_b', 'custom_c']
const widget = createMockWidget('custom_a', { values: customOptions })
const wrapper = mountComponent(widget, 'custom_a')
const emitted = await setSelectValueAndEmit(wrapper, 'custom_b')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('custom_b')
})
it('handles missing callback gracefully', async () => {
const widget = createMockWidget('option1', {}, undefined)
const wrapper = mountComponent(widget, 'option1')
const emitted = await setSelectValueAndEmit(wrapper, 'option2')
// Should emit Vue event
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('option2')
})
it('handles value changes gracefully', async () => {
const widget = createMockWidget('option1')
const wrapper = mountComponent(widget, 'option1')
const emitted = await setSelectValueAndEmit(wrapper, 'option2')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('option2')
})
})
describe('Option Handling', () => {
it('handles empty options array', async () => {
const widget = createMockWidget('', { values: [] })
const wrapper = mountComponent(widget, '')
const select = wrapper.findComponent({ name: 'Select' })
expect(select.props('options')).toEqual([])
})
it('handles single option', async () => {
const widget = createMockWidget('only_option', {
values: ['only_option']
})
const wrapper = mountComponent(widget, 'only_option')
const select = wrapper.findComponent({ name: 'Select' })
const options = select.props('options')
expect(options).toHaveLength(1)
expect(options[0]).toEqual('only_option')
})
it('handles options with special characters', async () => {
const specialOptions = [
'option with spaces',
'option@#$%',
'option/with\\slashes'
]
const widget = createMockWidget(specialOptions[0], {
values: specialOptions
})
const wrapper = mountComponent(widget, specialOptions[0])
const emitted = await setSelectValueAndEmit(wrapper, specialOptions[1])
expect(emitted).toBeDefined()
expect(emitted![0]).toContain(specialOptions[1])
})
})
describe('Edge Cases', () => {
it('handles selection of non-existent option gracefully', async () => {
const widget = createMockWidget('option1')
const wrapper = mountComponent(widget, 'option1')
const emitted = await setSelectValueAndEmit(
wrapper,
'non_existent_option'
)
// Should still emit Vue event with the value
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('non_existent_option')
})
it('handles numeric string options correctly', async () => {
const numericOptions = ['1', '2', '10', '100']
const widget = createMockWidget('1', { values: numericOptions })
const wrapper = mountComponent(widget, '1')
const emitted = await setSelectValueAndEmit(wrapper, '100')
// Should maintain string type in emitted event
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('100')
})
})
describe('node-type prop passing', () => {
it('passes node-type prop to WidgetSelectDropdown', () => {
const spec: ComboInputSpec = {
type: 'COMBO',
name: 'test_select',
image_upload: true
}
const widget = createMockWidget('option1', {}, undefined, spec)
const wrapper = mount(WidgetSelect, {
props: {
widget,
modelValue: 'option1',
nodeType: 'CheckpointLoaderSimple'
},
global: {
plugins: [PrimeVue, createTestingPinia()],
components: { Select }
}
})
const dropdown = wrapper.findComponent(WidgetSelectDropdown)
expect(dropdown.exists()).toBe(true)
expect(dropdown.props('nodeType')).toBe('CheckpointLoaderSimple')
})
it('does not pass node-type prop to WidgetSelectDefault', () => {
const widget = createMockWidget('option1')
const wrapper = mount(WidgetSelect, {
props: {
widget,
modelValue: 'option1',
nodeType: 'KSampler'
},
global: {
plugins: [PrimeVue, createTestingPinia()],
components: { Select }
}
})
const defaultSelect = wrapper.findComponent(WidgetSelectDefault)
expect(defaultSelect.exists()).toBe(true)
})
})
describe('Asset mode detection', () => {
it('enables asset mode when all conditions are met', () => {
mockDistributionState.isCloud = true
mockSettingStoreGet.mockReturnValue(true)
mockIsAssetBrowserEligible.mockReturnValue(true)
const widget = createMockWidget('test.safetensors')
const wrapper = mount(WidgetSelect, {
props: {
widget,
modelValue: 'test.safetensors',
nodeType: 'CheckpointLoaderSimple'
},
global: {
plugins: [PrimeVue, createTestingPinia()],
components: { Select }
}
})
expect(wrapper.findComponent(WidgetSelectDropdown).exists()).toBe(true)
})
it('disables asset mode when conditions are not met', () => {
mockDistributionState.isCloud = false
const widget = createMockWidget('test.safetensors')
const wrapper = mount(WidgetSelect, {
props: {
widget,
modelValue: 'test.safetensors',
nodeType: 'CheckpointLoaderSimple'
},
global: {
plugins: [PrimeVue, createTestingPinia()],
components: { Select }
}
})
expect(wrapper.findComponent(WidgetSelectDefault).exists()).toBe(true)
})
})
describe('Spec-aware rendering', () => {
it('uses dropdown variant when combo spec enables image uploads', () => {
const spec: ComboInputSpec = {
type: 'COMBO',
name: 'test_select',
image_upload: true
}
const widget = createMockWidget('option1', {}, undefined, spec)
const wrapper = mountComponent(widget, 'option1')
expect(wrapper.findComponent(WidgetSelectDropdown).exists()).toBe(true)
expect(wrapper.findComponent(WidgetSelectDefault).exists()).toBe(false)
})
it('uses dropdown variant for audio uploads', (context) => {
context.skip('allowUpload is not false, should it be? needs diagnosis')
const spec: ComboInputSpec = {
type: 'COMBO',
name: 'test_select',
audio_upload: true
}
const widget = createMockWidget('clip.wav', {}, undefined, spec)
const wrapper = mountComponent(widget, 'clip.wav')
const dropdown = wrapper.findComponent(WidgetSelectDropdown)
expect(dropdown.exists()).toBe(true)
expect(dropdown.props('assetKind')).toBe('audio')
expect(dropdown.props('allowUpload')).toBe(false)
})
it('keeps default select when no spec or media hints are present', () => {
const widget = createMockWidget('plain', {
values: ['plain', 'text']
})
const wrapper = mountComponent(widget, 'plain')
expect(wrapper.findComponent(WidgetSelectDefault).exists()).toBe(true)
expect(wrapper.findComponent(WidgetSelectDropdown).exists()).toBe(false)
})
})
})

View File

@@ -0,0 +1,174 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import type { ComponentPublicInstance } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { DropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
interface WidgetSelectDropdownInstance extends ComponentPublicInstance {
inputItems: DropdownItem[]
outputItems: DropdownItem[]
updateSelectedItems: (selectedSet: Set<string>) => void
}
describe('WidgetSelectDropdown custom label mapping', () => {
const createMockWidget = (
value: string = 'img_001.png',
options: {
values?: string[]
getOptionLabel?: (value: string | null) => string
} = {},
spec?: ComboInputSpec
): SimplifiedWidget<string | undefined> => ({
name: 'test_image_select',
type: 'combo',
value,
options: {
values: ['img_001.png', 'photo_abc.jpg', 'hash789.png'],
...options
},
spec
})
const mountComponent = (
widget: SimplifiedWidget<string | undefined>,
modelValue: string | undefined,
assetKind: 'image' | 'video' | 'audio' = 'image'
): VueWrapper<WidgetSelectDropdownInstance> => {
return mount(WidgetSelectDropdown, {
props: {
widget,
modelValue,
assetKind,
allowUpload: true,
uploadFolder: 'input'
},
global: {
plugins: [PrimeVue, createTestingPinia()]
}
}) as unknown as VueWrapper<WidgetSelectDropdownInstance>
}
describe('when custom labels are not provided', () => {
it('uses values as labels when no mapping provided', () => {
const widget = createMockWidget('img_001.png')
const wrapper = mountComponent(widget, 'img_001.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems).toHaveLength(3)
expect(inputItems[0].name).toBe('img_001.png')
expect(inputItems[0].label).toBe('img_001.png')
expect(inputItems[1].name).toBe('photo_abc.jpg')
expect(inputItems[1].label).toBe('photo_abc.jpg')
expect(inputItems[2].name).toBe('hash789.png')
expect(inputItems[2].label).toBe('hash789.png')
})
})
describe('when custom labels are provided via getOptionLabel', () => {
it('displays custom labels while preserving original values', () => {
const getOptionLabel = vi.fn((value: string | null) => {
if (!value) return 'No file'
const mapping: Record<string, string> = {
'img_001.png': 'Vacation Photo',
'photo_abc.jpg': 'Family Portrait',
'hash789.png': 'Sunset Beach'
}
return mapping[value] || value
})
const widget = createMockWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems).toHaveLength(3)
expect(inputItems[0].name).toBe('img_001.png')
expect(inputItems[0].label).toBe('Vacation Photo')
expect(inputItems[1].name).toBe('photo_abc.jpg')
expect(inputItems[1].label).toBe('Family Portrait')
expect(inputItems[2].name).toBe('hash789.png')
expect(inputItems[2].label).toBe('Sunset Beach')
expect(getOptionLabel).toHaveBeenCalledWith('img_001.png')
expect(getOptionLabel).toHaveBeenCalledWith('photo_abc.jpg')
expect(getOptionLabel).toHaveBeenCalledWith('hash789.png')
})
it('emits original values when items with custom labels are selected', async () => {
const getOptionLabel = vi.fn((value: string | null) => {
if (!value) return 'No file'
return `Custom: ${value}`
})
const widget = createMockWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
// Simulate selecting an item
const selectedSet = new Set(['input-1']) // index 1 = photo_abc.jpg
wrapper.vm.updateSelectedItems(selectedSet)
// Should emit the original value, not the custom label
expect(wrapper.emitted('update:modelValue')).toBeDefined()
expect(wrapper.emitted('update:modelValue')![0]).toEqual([
'photo_abc.jpg'
])
})
it('falls back to original value when label mapping fails', () => {
const getOptionLabel = vi.fn((value: string | null) => {
if (value === 'photo_abc.jpg') {
throw new Error('Mapping failed')
}
return `Labeled: ${value}`
})
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const widget = createMockWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems[0].name).toBe('img_001.png')
expect(inputItems[0].label).toBe('Labeled: img_001.png')
expect(inputItems[1].name).toBe('photo_abc.jpg')
expect(inputItems[1].label).toBe('photo_abc.jpg')
expect(inputItems[2].name).toBe('hash789.png')
expect(inputItems[2].label).toBe('Labeled: hash789.png')
expect(consoleErrorSpy).toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
})
describe('output items with custom label mapping', () => {
it('applies custom label mapping to output items from queue history', () => {
const getOptionLabel = vi.fn((value: string | null) => {
if (!value) return 'No file'
return `Output: ${value}`
})
const widget = createMockWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
const outputItems = wrapper.vm.outputItems
expect(outputItems).toBeDefined()
expect(Array.isArray(outputItems)).toBe(true)
})
})
})

View File

@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
const mockUpdateModelsForNodeType = vi.fn()
const mockGetCategoryForNodeType = vi.fn()
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
modelAssetsByNodeType: new Map(),
modelLoadingByNodeType: new Map(),
modelErrorByNodeType: new Map(),
updateModelsForNodeType: mockUpdateModelsForNodeType
})
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
getCategoryForNodeType: mockGetCategoryForNodeType
})
}))
describe('useAssetWidgetData (desktop/isCloud=false)', () => {
it('returns empty/default values without calling stores', () => {
const nodeType = ref('CheckpointLoaderSimple')
const { category, assets, dropdownItems, isLoading, error } =
useAssetWidgetData(nodeType)
expect(category.value).toBeUndefined()
expect(assets.value).toEqual([])
expect(dropdownItems.value).toEqual([])
expect(isLoading.value).toBe(false)
expect(error.value).toBeNull()
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
expect(mockGetCategoryForNodeType).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,245 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
const mockModelAssetsByNodeType = new Map<string, AssetItem[]>()
const mockModelLoadingByNodeType = new Map<string, boolean>()
const mockModelErrorByNodeType = new Map<string, Error | null>()
const mockUpdateModelsForNodeType = vi.fn()
const mockGetCategoryForNodeType = vi.fn()
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
modelAssetsByNodeType: mockModelAssetsByNodeType,
modelLoadingByNodeType: mockModelLoadingByNodeType,
modelErrorByNodeType: mockModelErrorByNodeType,
updateModelsForNodeType: mockUpdateModelsForNodeType
})
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
getCategoryForNodeType: mockGetCategoryForNodeType
})
}))
describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockModelAssetsByNodeType.clear()
mockModelLoadingByNodeType.clear()
mockModelErrorByNodeType.clear()
mockGetCategoryForNodeType.mockReturnValue(undefined)
mockUpdateModelsForNodeType.mockImplementation(
async (): Promise<AssetItem[]> => {
return []
}
)
})
const createMockAsset = (
id: string,
name: string,
filename: string,
previewUrl?: string
): AssetItem => ({
id,
name,
size: 1024,
tags: ['models', 'checkpoints'],
created_at: '2025-01-01T00:00:00Z',
preview_url: previewUrl,
user_metadata: {
filename
}
})
it('fetches assets and transforms to dropdown items', async () => {
const mockAssets: AssetItem[] = [
createMockAsset(
'asset-1',
'Beautiful Model',
'models/beautiful_model.safetensors',
'/api/preview/asset-1'
),
createMockAsset('asset-2', 'Model B', 'model_b.safetensors', '/preview/2')
]
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockModelAssetsByNodeType.set(_nodeType, mockAssets)
mockModelLoadingByNodeType.set(_nodeType, false)
return mockAssets
}
)
const nodeType = ref('CheckpointLoaderSimple')
const { category, assets, dropdownItems, isLoading } =
useAssetWidgetData(nodeType)
await nextTick()
await vi.waitFor(() => !isLoading.value)
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple'
)
expect(category.value).toBe('checkpoints')
expect(assets.value).toEqual(mockAssets)
expect(dropdownItems.value).toHaveLength(2)
const item = dropdownItems.value[0]
expect(item.id).toBe('asset-1')
expect(item.name).toBe('models/beautiful_model.safetensors')
expect(item.label).toBe('Beautiful Model')
expect(item.mediaSrc).toBe('/api/preview/asset-1')
})
it('handles API errors gracefully', async () => {
const mockError = new Error('Network error')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockModelErrorByNodeType.set(_nodeType, mockError)
mockModelAssetsByNodeType.set(_nodeType, [])
mockModelLoadingByNodeType.set(_nodeType, false)
return []
}
)
const nodeType = ref('CheckpointLoaderSimple')
const { assets, error, isLoading } = useAssetWidgetData(nodeType)
await nextTick()
await vi.waitFor(() => !isLoading.value)
expect(error.value).toBe(mockError)
expect(assets.value).toEqual([])
})
it('returns empty for unknown node type', async () => {
mockGetCategoryForNodeType.mockReturnValue(undefined)
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockModelAssetsByNodeType.set(_nodeType, [])
mockModelLoadingByNodeType.set(_nodeType, false)
return []
}
)
const nodeType = ref('UnknownNodeType')
const { category, assets } = useAssetWidgetData(nodeType)
await nextTick()
expect(category.value).toBeUndefined()
expect(assets.value).toEqual([])
})
describe('MaybeRefOrGetter parameter support', () => {
it('accepts plain string value', async () => {
const mockAssets: AssetItem[] = [
createMockAsset('asset-1', 'Model A', 'model_a.safetensors')
]
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockModelAssetsByNodeType.set(_nodeType, mockAssets)
mockModelLoadingByNodeType.set(_nodeType, false)
return mockAssets
}
)
const { category, assets, isLoading } = useAssetWidgetData(
'CheckpointLoaderSimple'
)
await nextTick()
await vi.waitFor(() => !isLoading.value)
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple'
)
expect(category.value).toBe('checkpoints')
expect(assets.value).toEqual(mockAssets)
})
it('accepts getter function', async () => {
const mockAssets: AssetItem[] = [
createMockAsset('asset-1', 'Model A', 'model_a.safetensors')
]
mockGetCategoryForNodeType.mockReturnValue('loras')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockModelAssetsByNodeType.set(_nodeType, mockAssets)
mockModelLoadingByNodeType.set(_nodeType, false)
return mockAssets
}
)
const nodeType = ref('LoraLoader')
const { category, assets, isLoading } = useAssetWidgetData(
() => nodeType.value
)
await nextTick()
await vi.waitFor(() => !isLoading.value)
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith('LoraLoader')
expect(category.value).toBe('loras')
expect(assets.value).toEqual(mockAssets)
})
it('accepts ref (backward compatibility)', async () => {
const mockAssets: AssetItem[] = [
createMockAsset('asset-1', 'Model A', 'model_a.safetensors')
]
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockModelAssetsByNodeType.set(_nodeType, mockAssets)
mockModelLoadingByNodeType.set(_nodeType, false)
return mockAssets
}
)
const nodeTypeRef = ref('CheckpointLoaderSimple')
const { category, assets, isLoading } = useAssetWidgetData(nodeTypeRef)
await nextTick()
await vi.waitFor(() => !isLoading.value)
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple'
)
expect(category.value).toBe('checkpoints')
expect(assets.value).toEqual(mockAssets)
})
it('handles undefined node type gracefully', async () => {
const { category, assets, dropdownItems, isLoading, error } =
useAssetWidgetData(undefined)
await nextTick()
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
expect(category.value).toBeUndefined()
expect(assets.value).toEqual([])
expect(dropdownItems.value).toEqual([])
expect(isLoading.value).toBe(false)
expect(error.value).toBeNull()
})
})
})

View File

@@ -0,0 +1,487 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { assetService } from '@/platform/assets/services/assetService'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useComboWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useComboWidget'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
// Mock factory using actual type
function createMockAssetItem(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'test-asset-id',
name: 'test-image.png',
asset_hash: 'hash123',
size: 1024,
mime_type: 'image/png',
tags: ['input'],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
last_access_time: new Date().toISOString(),
...overrides
}
}
// Use vi.hoisted() to ensure mock state is initialized before mocks
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
const mockUpdateInputs = vi.hoisted(() => vi.fn(() => Promise.resolve()))
const mockGetInputName = vi.hoisted(() => vi.fn((hash: string) => hash))
const mockAssetsStoreState = vi.hoisted(() => {
const inputAssets: AssetItem[] = []
return {
inputAssets,
inputLoading: false
}
})
vi.mock('@/scripts/widgets', () => ({
addValueControlWidgets: vi.fn()
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockDistributionState.isCloud
}
}))
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: vi.fn(() => ({
get inputAssets() {
return mockAssetsStoreState.inputAssets
},
get inputLoading() {
return mockAssetsStoreState.inputLoading
},
updateInputs: mockUpdateInputs,
getInputName: mockGetInputName
}))
}))
const mockSettingStoreGet = vi.fn(() => false)
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: mockSettingStoreGet
}))
}))
vi.mock('@/i18n', () => ({
t: vi.fn((key: string) =>
key === 'widgets.selectModel' ? 'Select model' : key
)
}))
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
isAssetBrowserEligible: vi.fn(() => false)
}
}))
vi.mock('@/platform/assets/composables/useAssetBrowserDialog', () => {
const mockAssetBrowserDialogShow = vi.fn()
return {
useAssetBrowserDialog: vi.fn(() => ({
show: mockAssetBrowserDialogShow
}))
}
})
// Test factory functions
function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
const mockCallback = vi.fn()
const widget: IBaseWidget = {
type: 'combo',
options: {},
name: 'testWidget',
value: undefined,
callback: mockCallback,
y: 0,
...overrides
}
return widget
}
function createMockNode(comfyClass = 'TestNode'): LGraphNode {
const node = new LGraphNode('TestNode')
node.comfyClass = comfyClass
// Spy on the addWidget method
vi.spyOn(node, 'addWidget').mockImplementation(
(type, name, value, callback) => {
const widget = createMockWidget({ type, name, value })
// Store the callback function on the widget for testing
if (typeof callback === 'function') {
widget.callback = callback
}
return widget
}
)
return node
}
function createMockInputSpec(overrides: Partial<InputSpec> = {}): InputSpec {
const inputSpec: InputSpec = {
type: 'COMBO',
name: 'testInput',
...overrides
}
return inputSpec
}
describe('useComboWidget', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSettingStoreGet.mockReturnValue(false)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(false)
vi.mocked(useAssetBrowserDialog).mockClear()
mockDistributionState.isCloud = false
mockAssetsStoreState.inputAssets = []
mockAssetsStoreState.inputLoading = false
mockUpdateInputs.mockClear()
})
it('should handle undefined spec', () => {
const constructor = useComboWidget()
const mockWidget = createMockWidget()
const mockNode = createMockNode()
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({ name: 'inputName' })
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'combo',
'inputName',
undefined,
expect.any(Function),
expect.objectContaining({
values: []
})
)
expect(widget).toBe(mockWidget)
})
it('should create normal combo widget when asset API is disabled', () => {
mockDistributionState.isCloud = true
mockSettingStoreGet.mockReturnValue(false) // Asset API disabled
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true) // Widget is eligible
const constructor = useComboWidget()
const mockWidget = createMockWidget()
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name',
options: ['model1.safetensors', 'model2.safetensors']
})
const widget = constructor(mockNode, inputSpec)
expect(widget).toBe(mockWidget)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'combo',
'ckpt_name',
'model1.safetensors',
expect.any(Function),
{ values: ['model1.safetensors', 'model2.safetensors'] }
)
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(widget).toBe(mockWidget)
})
it('should create asset browser widget when API enabled', () => {
mockDistributionState.isCloud = true
mockSettingStoreGet.mockReturnValue(true)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'asset',
name: 'ckpt_name',
value: 'model1.safetensors'
})
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name',
options: ['model1.safetensors', 'model2.safetensors']
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'asset',
'ckpt_name',
'model1.safetensors',
expect.any(Function)
)
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
'CheckpointLoaderSimple',
'ckpt_name'
)
expect(widget).toBe(mockWidget)
})
it('should create asset browser widget when default value provided without options', () => {
mockDistributionState.isCloud = true
mockSettingStoreGet.mockReturnValue(true)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'asset',
name: 'ckpt_name',
value: 'fallback.safetensors'
})
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name',
default: 'fallback.safetensors'
// Note: no options array provided
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'asset',
'ckpt_name',
'fallback.safetensors',
expect.any(Function)
)
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(widget).toBe(mockWidget)
})
it('should show Select model when asset widget has undefined current value', () => {
mockDistributionState.isCloud = true
mockSettingStoreGet.mockReturnValue(true)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'asset',
name: 'ckpt_name',
value: 'Select model'
})
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name'
// Note: no default, no options, not remote - getDefaultValue returns undefined
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'asset',
'ckpt_name',
'Select model', // Should fallback to this instead of undefined
expect.any(Function)
)
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(widget).toBe(mockWidget)
})
describe('cloud input asset mapping', () => {
const HASH_FILENAME =
'72e786ff2a44d682c4294db0b7098e569832bc394efc6dad644e6ec85a78efb7.png'
const HASH_FILENAME_2 =
'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456.jpg'
it.each([
{ nodeClass: 'LoadImage', inputName: 'image' },
{ nodeClass: 'LoadVideo', inputName: 'video' },
{ nodeClass: 'LoadAudio', inputName: 'audio' }
])(
'should create combo widget with getOptionLabel for $nodeClass in cloud',
({ nodeClass, inputName }) => {
mockDistributionState.isCloud = true
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'combo',
name: inputName,
value: HASH_FILENAME
})
const mockNode = createMockNode(nodeClass)
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: inputName,
options: [HASH_FILENAME, HASH_FILENAME_2]
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'combo',
inputName,
HASH_FILENAME,
expect.any(Function),
expect.objectContaining({
values: [], // Empty initially, populated dynamically by Proxy
getOptionLabel: expect.any(Function)
})
)
expect(widget).toBe(mockWidget)
}
)
it("should format option labels using store's getInputName function", () => {
mockDistributionState.isCloud = true
mockGetInputName.mockReturnValue('Beautiful Sunset.png')
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'combo',
name: 'image',
value: HASH_FILENAME
})
const mockNode = createMockNode('LoadImage')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'image',
options: [HASH_FILENAME]
})
constructor(mockNode, inputSpec)
// Extract the injected getOptionLabel function with type narrowing
const addWidgetCall = vi.mocked(mockNode.addWidget).mock.calls[0]
const options = addWidgetCall[4]
if (typeof options !== 'object' || !options) {
throw new Error('Expected options to be an object')
}
if (!('getOptionLabel' in options)) {
throw new Error('Expected options to have getOptionLabel property')
}
if (typeof options.getOptionLabel !== 'function') {
throw new Error('Expected getOptionLabel to be a function')
}
// Test that the injected function calls getInputName
const result = options.getOptionLabel(HASH_FILENAME)
expect(mockGetInputName).toHaveBeenCalledWith(HASH_FILENAME)
expect(result).toBe('Beautiful Sunset.png')
})
it('should create normal combo widget for non-input nodes in cloud', () => {
mockDistributionState.isCloud = true
const constructor = useComboWidget()
const mockWidget = createMockWidget()
const mockNode = createMockNode('SomeOtherNode')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'option',
options: [HASH_FILENAME, HASH_FILENAME_2]
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'combo',
'option',
HASH_FILENAME,
expect.any(Function),
{ values: [HASH_FILENAME, HASH_FILENAME_2] }
)
expect(widget).toBe(mockWidget)
})
it('should create normal combo widget for LoadImage in OSS', () => {
mockDistributionState.isCloud = false
const constructor = useComboWidget()
const mockWidget = createMockWidget()
const mockNode = createMockNode('LoadImage')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'image',
options: [HASH_FILENAME, HASH_FILENAME_2]
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'combo',
'image',
HASH_FILENAME,
expect.any(Function),
{
values: [HASH_FILENAME, HASH_FILENAME_2]
}
)
expect(widget).toBe(mockWidget)
})
it('should trigger lazy load for cloud input nodes', () => {
mockDistributionState.isCloud = true
mockAssetsStoreState.inputAssets = []
mockAssetsStoreState.inputLoading = false
const constructor = useComboWidget()
const mockWidget = createMockWidget({ type: 'combo' })
const mockNode = createMockNode('LoadImage')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'image',
options: [HASH_FILENAME]
})
constructor(mockNode, inputSpec)
expect(mockUpdateInputs).toHaveBeenCalledTimes(1)
})
it('should not trigger lazy load if assets already loading', () => {
mockDistributionState.isCloud = true
mockAssetsStoreState.inputAssets = []
mockAssetsStoreState.inputLoading = true
const constructor = useComboWidget()
const mockWidget = createMockWidget({ type: 'combo' })
const mockNode = createMockNode('LoadImage')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'image',
options: [HASH_FILENAME]
})
constructor(mockNode, inputSpec)
expect(mockUpdateInputs).not.toHaveBeenCalled()
})
it('should not trigger lazy load if assets already loaded', () => {
mockDistributionState.isCloud = true
mockAssetsStoreState.inputAssets = [
createMockAssetItem({
id: 'asset-123',
name: 'image1.png',
asset_hash: HASH_FILENAME
})
]
mockAssetsStoreState.inputLoading = false
const constructor = useComboWidget()
const mockWidget = createMockWidget({ type: 'combo' })
const mockNode = createMockNode('LoadImage')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'image',
options: [HASH_FILENAME]
})
constructor(mockNode, inputSpec)
expect(mockUpdateInputs).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { _for_testing } from '@/renderer/extensions/vueNodes/widgets/composables/useFloatWidget'
vi.mock('@/scripts/widgets', () => ({
addValueControlWidgets: vi.fn()
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
settings: {}
})
}))
const { onFloatValueChange } = _for_testing
describe('useFloatWidget', () => {
describe('onFloatValueChange', () => {
let widget: any
beforeEach(() => {
// Reset the widget before each test
widget = {
options: {},
value: 0
}
})
it('should not round values when round option is not set', () => {
widget.options.round = undefined
onFloatValueChange.call(widget, 5.7)
expect(widget.value).toBe(5.7)
})
it('should round values based on round option', () => {
widget.options.round = 0.5
onFloatValueChange.call(widget, 5.7)
expect(widget.value).toBe(5.5)
widget.options.round = 0.1
onFloatValueChange.call(widget, 5.74)
expect(widget.value).toBe(5.7)
widget.options.round = 1
onFloatValueChange.call(widget, 5.7)
expect(widget.value).toBe(6)
})
it('should respect min and max constraints after rounding', () => {
widget.options.round = 0.5
widget.options.min = 1
widget.options.max = 5
// Should round to 1 and respect min
onFloatValueChange.call(widget, 0.7)
expect(widget.value).toBe(1)
// Should round to 5.5 but be clamped to max of 5
onFloatValueChange.call(widget, 5.3)
expect(widget.value).toBe(5)
// Should round to 3.5 and be within bounds
onFloatValueChange.call(widget, 3.6)
expect(widget.value).toBe(3.5)
})
it('should handle Number.EPSILON for precision issues', () => {
widget.options.round = 0.1
// Without Number.EPSILON, 1.35 / 0.1 = 13.499999999999998
// which would round to 13 * 0.1 = 1.3 instead of 1.4
onFloatValueChange.call(widget, 1.35)
expect(widget.value).toBeCloseTo(1.4, 10)
// Test another edge case
onFloatValueChange.call(widget, 2.95)
expect(widget.value).toBeCloseTo(3, 10)
})
})
})

View File

@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { _for_testing } from '@/renderer/extensions/vueNodes/widgets/composables/useIntWidget'
vi.mock('@/scripts/widgets', () => ({
addValueControlWidgets: vi.fn()
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
settings: {}
})
}))
const { onValueChange } = _for_testing
describe('useIntWidget', () => {
describe('onValueChange', () => {
let widget: any
beforeEach(() => {
// Reset the widget before each test
widget = {
options: {},
value: 0
}
})
it('should round values based on step size', () => {
widget.options.step2 = 0.1
onValueChange.call(widget, 5.7)
expect(widget.value).toBe(5.7)
widget.options.step2 = 0.5
onValueChange.call(widget, 7.3)
expect(widget.value).toBe(7.5)
widget.options.step2 = 1
onValueChange.call(widget, 23.4)
expect(widget.value).toBe(23)
})
it('should handle undefined step by using default of 1', () => {
widget.options.step2 = undefined
onValueChange.call(widget, 3.7)
expect(widget.value).toBe(4)
})
it('should account for min value offset', () => {
widget.options.step2 = 2
widget.options.min = 1
// 2 valid values between 1.6 are 1 and 3
// 1.6 is closer to 1, so it should round to 1
onValueChange.call(widget, 1.6)
expect(widget.value).toBe(1)
})
it('should handle undefined min by using default of 0', () => {
widget.options.step2 = 2
widget.options.min = undefined
onValueChange.call(widget, 5.7)
expect(widget.value).toBe(6)
})
it('should handle NaN shift value', () => {
widget.options.step2 = 0
widget.options.min = 1
onValueChange.call(widget, 5.7)
expect(widget.value).toBe(6)
})
})
})

View File

@@ -0,0 +1,746 @@
import axios from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { IWidget } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useRemoteWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
const createMockNode = (overrides: Partial<LGraphNode> = {}): LGraphNode => {
const node = new LGraphNode('TestNode')
Object.assign(node, overrides)
return node
}
const createMockWidget = (overrides = {}): IWidget =>
({ ...overrides }) as unknown as IWidget
const mockCloudAuth = vi.hoisted(() => ({
isCloud: false,
authHeader: null as { Authorization: string } | null
}))
vi.mock('axios', async (importOriginal) => {
const actual = await importOriginal<typeof axios>()
return {
default: {
...actual,
get: vi.fn()
}
}
})
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockCloudAuth.isCloud
}
}))
vi.mock('@/stores/firebaseAuthStore', async () => {
return {
useFirebaseAuthStore: vi.fn(() => ({
getAuthHeader: vi.fn(() => Promise.resolve(mockCloudAuth.authHeader))
}))
}
})
vi.mock('@/platform/settings/settingStore', async () => {
return {
useSettingStore: () => ({
settings: {}
})
}
})
const FIRST_BACKOFF = 1000 // backoff is 1s on first retry
const DEFAULT_VALUE = 'Loading...'
function createMockConfig(overrides = {}): RemoteWidgetConfig {
return {
route: `/api/test/${Date.now()}${Math.random().toString(36).substring(2, 15)}`,
refresh: 0,
...overrides
}
}
const createMockOptions = (inputOverrides = {}) => ({
remoteConfig: createMockConfig(inputOverrides),
defaultValue: DEFAULT_VALUE,
node: createMockNode(),
widget: createMockWidget()
})
function mockAxiosResponse(data: unknown, status = 200) {
vi.mocked(axios.get).mockResolvedValueOnce({ data, status })
}
function mockAxiosError(error: Error | string) {
const err = error instanceof Error ? error : new Error(error)
vi.mocked(axios.get).mockRejectedValueOnce(err)
}
function createHookWithData(data: unknown, inputOverrides = {}) {
mockAxiosResponse(data)
const hook = useRemoteWidget(createMockOptions(inputOverrides))
return hook
}
async function setupHookWithResponse(data: unknown, inputOverrides = {}) {
const hook = createHookWithData(data, inputOverrides)
const result = await getResolvedValue(hook)
return { hook, result }
}
async function getResolvedValue(hook: ReturnType<typeof useRemoteWidget>) {
// Create a promise that resolves when the fetch is complete
const responsePromise = new Promise<void>((resolve) => {
hook.getValue(() => resolve())
})
await responsePromise
return hook.getCachedValue()
}
describe('useRemoteWidget', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset mocks
vi.mocked(axios.get).mockReset()
// Reset cache between tests
vi.spyOn(Map.prototype, 'get').mockClear()
vi.spyOn(Map.prototype, 'set').mockClear()
vi.spyOn(Map.prototype, 'delete').mockClear()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('initialization', () => {
it('should create hook with default values', () => {
const hook = useRemoteWidget(createMockOptions())
expect(hook.getCachedValue()).toBeUndefined()
expect(hook.getValue()).toBe('Loading...')
})
it('should generate consistent cache keys', () => {
const options = createMockOptions()
const hook1 = useRemoteWidget(options)
const hook2 = useRemoteWidget(options)
expect(hook1.cacheKey).toBe(hook2.cacheKey)
})
it('should handle query params in cache key', () => {
const hook1 = useRemoteWidget(
createMockOptions({ query_params: { a: 1 } })
)
const hook2 = useRemoteWidget(
createMockOptions({ query_params: { a: 2 } })
)
expect(hook1.cacheKey).not.toBe(hook2.cacheKey)
})
})
describe('fetchOptions', () => {
it('should fetch data successfully', async () => {
const mockData = ['optionA', 'optionB']
const { hook, result } = await setupHookWithResponse(mockData)
expect(result).toEqual(mockData)
expect(vi.mocked(axios.get)).toHaveBeenCalledWith(
hook.cacheKey.split(';')[0], // Get the route part from cache key
expect.any(Object)
)
})
it('should use response_key if provided', async () => {
const mockResponse = { items: ['optionB', 'optionA', 'optionC'] }
const { result } = await setupHookWithResponse(mockResponse, {
response_key: 'items'
})
expect(result).toEqual(mockResponse.items)
})
it('should cache successful responses', async () => {
const mockData = ['optionA', 'optionB', 'optionC', 'optionD']
const { hook } = await setupHookWithResponse(mockData)
const entry = hook.getCacheEntry()
expect(entry?.data).toEqual(mockData)
expect(entry?.error).toBeNull()
})
it('should handle fetch errors', async () => {
const error = new Error('Network error')
mockAxiosError(error)
const { hook } = await setupHookWithResponse([])
const entry = hook.getCacheEntry()
expect(entry?.error).toBeTruthy()
expect(entry?.lastErrorTime).toBeDefined()
})
it('should handle empty array responses', async () => {
const { result } = await setupHookWithResponse([])
expect(result).toEqual([])
})
it('should handle malformed response data', async () => {
const hook = useRemoteWidget(createMockOptions())
mockAxiosResponse(null)
const data1 = hook.getValue()
mockAxiosResponse(undefined)
const data2 = hook.getValue()
expect(data1).toBe(DEFAULT_VALUE)
expect(data2).toBe(DEFAULT_VALUE)
})
it('should handle non-200 status codes', async () => {
mockAxiosError('Request failed with status code 404')
const { hook } = await setupHookWithResponse([])
const entry = hook.getCacheEntry()
expect(entry?.error?.message).toBe('Request failed with status code 404')
})
})
describe('refresh behavior', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
describe('permanent widgets (no refresh)', () => {
it('permanent widgets should not attempt fetch after initialization', async () => {
const mockData = ['data that is permanent after initialization']
const { hook } = await setupHookWithResponse(mockData)
await getResolvedValue(hook)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
it('permanent widgets should re-fetch if refreshValue is called', async () => {
const mockData = ['data that is permanent after initialization']
const { hook } = await setupHookWithResponse(mockData)
await getResolvedValue(hook)
expect(hook.getCachedValue()).toEqual(mockData)
const refreshedData = ['data that user forced to be fetched']
mockAxiosResponse(refreshedData)
hook.refreshValue()
// Wait for cache to update with refreshed data
await vi.waitFor(() => {
expect(hook.getCachedValue()).toEqual(refreshedData)
})
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
it('permanent widgets should still retry if request fails', async () => {
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
vi.setSystemTime(Date.now() + FIRST_BACKOFF)
const secondData = await getResolvedValue(hook)
expect(secondData).toBe('Loading...')
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
it('should treat empty refresh field as permanent', async () => {
const { hook } = await setupHookWithResponse(['data that is permanent'])
await getResolvedValue(hook)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
})
it('should refresh when data is stale', async () => {
const refresh = 256
const mockData1 = ['option1']
const mockData2 = ['option2']
const { hook } = await setupHookWithResponse(mockData1, { refresh })
mockAxiosResponse(mockData2)
vi.setSystemTime(Date.now() + refresh)
const newData = await getResolvedValue(hook)
expect(newData).toEqual(mockData2)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
it('should not refresh when data is not stale', async () => {
const { hook } = await setupHookWithResponse(['option1'], {
refresh: 512
})
vi.setSystemTime(Date.now() + 128)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
it('should use backoff instead of refresh after error', async () => {
const refresh = 4096
const { hook } = await setupHookWithResponse(['first success'], {
refresh
})
mockAxiosError('Network error')
vi.setSystemTime(Date.now() + refresh)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
mockAxiosResponse(['second success'])
vi.setSystemTime(Date.now() + FIRST_BACKOFF)
const thirdData = await getResolvedValue(hook)
expect(thirdData).toEqual(['second success'])
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(3)
})
it('should use last valid value after error', async () => {
const refresh = 4096
const { hook } = await setupHookWithResponse(['a valid value'], {
refresh
})
mockAxiosError('Network error')
vi.setSystemTime(Date.now() + refresh)
const secondData = await getResolvedValue(hook)
expect(secondData).toEqual(['a valid value'])
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
})
describe('error handling and backoff', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('should implement exponential backoff on errors', async () => {
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
const entry1 = hook.getCacheEntry()
expect(entry1?.error).toBeTruthy()
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
vi.setSystemTime(Date.now() + 500)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1) // Still backing off
vi.setSystemTime(Date.now() + 3000)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
expect(entry1?.data).toBeDefined()
})
it('should reset error state on successful fetch', async () => {
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
const firstData = await getResolvedValue(hook)
expect(firstData).toBe('Loading...')
vi.setSystemTime(Date.now() + 3000)
mockAxiosResponse(['option1'])
const secondData = await getResolvedValue(hook)
expect(secondData).toEqual(['option1'])
const entry = hook.getCacheEntry()
expect(entry?.error).toBeNull()
expect(entry?.retryCount).toBe(0)
})
it('should save successful data after backoff', async () => {
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
const entry1 = hook.getCacheEntry()
expect(entry1?.error).toBeTruthy()
vi.setSystemTime(Date.now() + 3000)
mockAxiosResponse(['success after backoff'])
const secondData = await getResolvedValue(hook)
expect(secondData).toEqual(['success after backoff'])
const entry2 = hook.getCacheEntry()
expect(entry2?.error).toBeNull()
expect(entry2?.retryCount).toBe(0)
})
it('should save successful data after multiple backoffs', async () => {
mockAxiosError('Network error')
mockAxiosError('Network error')
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
const entry1 = hook.getCacheEntry()
expect(entry1?.error).toBeTruthy()
vi.setSystemTime(Date.now() + 3000)
const secondData = await getResolvedValue(hook)
expect(secondData).toBe('Loading...')
expect(entry1?.error).toBeDefined()
vi.setSystemTime(Date.now() + 9000)
const thirdData = await getResolvedValue(hook)
expect(thirdData).toBe('Loading...')
expect(entry1?.error).toBeDefined()
vi.setSystemTime(Date.now() + 120_000)
mockAxiosResponse(['success after multiple backoffs'])
const fourthData = await getResolvedValue(hook)
expect(fourthData).toEqual(['success after multiple backoffs'])
const entry2 = hook.getCacheEntry()
expect(entry2?.error).toBeNull()
expect(entry2?.retryCount).toBe(0)
})
})
describe('cache management', () => {
it('should clear cache entries', async () => {
const { hook } = await setupHookWithResponse(['to be cleared'])
expect(hook.getCachedValue()).toBeDefined()
hook.refreshValue()
expect(hook.getCachedValue()).toBe(DEFAULT_VALUE)
})
it('should prevent duplicate in-flight requests', async () => {
const mockData = ['non-duplicate']
mockAxiosResponse(mockData)
const hook = useRemoteWidget(createMockOptions())
// Start two concurrent getValue calls
const promise1 = new Promise<void>((resolve) => {
hook.getValue(() => resolve())
})
const promise2 = new Promise<void>((resolve) => {
hook.getValue(() => resolve())
})
// Wait for both e
await Promise.all([promise1, promise2])
// Both should see the same cached data
expect(hook.getCachedValue()).toEqual(mockData)
// Only one axios call should have been made
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
})
describe('concurrent access and multiple instances', () => {
it('should handle concurrent hook instances with same route', async () => {
mockAxiosResponse(['shared data'])
const options = createMockOptions()
const hook1 = useRemoteWidget(options)
const hook2 = useRemoteWidget(options)
// Since they have the same route, only one request will be made
await Promise.race([getResolvedValue(hook1), getResolvedValue(hook2)])
const data1 = hook1.getValue()
const data2 = hook2.getValue()
expect(data1).toEqual(['shared data'])
expect(data2).toEqual(['shared data'])
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
expect(hook1.getCachedValue()).toBe(hook2.getCachedValue())
})
it('should use shared cache across multiple hooks', async () => {
mockAxiosResponse(['shared data'])
const options = createMockOptions()
const hook1 = useRemoteWidget(options)
const hook2 = useRemoteWidget(options)
const hook3 = useRemoteWidget(options)
const hook4 = useRemoteWidget(options)
const data1 = await getResolvedValue(hook1)
const data2 = await getResolvedValue(hook2)
const data3 = await getResolvedValue(hook3)
const data4 = await getResolvedValue(hook4)
expect(data1).toEqual(['shared data'])
expect(data2).toBe(data1)
expect(data3).toBe(data1)
expect(data4).toBe(data1)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
expect(hook1.getCachedValue()).toBe(hook2.getCachedValue())
expect(hook2.getCachedValue()).toBe(hook3.getCachedValue())
expect(hook3.getCachedValue()).toBe(hook4.getCachedValue())
})
it('should handle rapid cache clearing during fetch', async () => {
let resolvePromise: (value: any) => void
const delayedPromise = new Promise((resolve) => {
resolvePromise = resolve
})
vi.mocked(axios.get).mockImplementationOnce(() => delayedPromise as any)
const hook = useRemoteWidget(createMockOptions())
hook.getValue()
hook.refreshValue()
resolvePromise!({ data: ['delayed data'] })
const data = await getResolvedValue(hook)
// The value should be the default value because the refreshValue
// clears the cache and the fetch is aborted
expect(data).toEqual(DEFAULT_VALUE)
expect(hook.getCachedValue()).toBe(DEFAULT_VALUE)
})
it('should handle widget destroyed during fetch', async () => {
let resolvePromise: (value: any) => void
const delayedPromise = new Promise((resolve) => {
resolvePromise = resolve
})
vi.mocked(axios.get).mockImplementationOnce(() => delayedPromise as any)
let hook = useRemoteWidget(createMockOptions())
const fetchPromise = hook.getValue()
hook = null as any
resolvePromise!({ data: ['delayed data'] })
await fetchPromise
expect(hook).toBeNull()
hook = useRemoteWidget(createMockOptions())
const data2 = await getResolvedValue(hook)
expect(data2).toEqual(DEFAULT_VALUE)
})
})
describe('cloud distribution authentication', () => {
describe('when distribution is cloud', () => {
describe('when authenticated', () => {
it('passes Firebase authentication token in request headers', async () => {
const mockData = ['authenticated data']
mockCloudAuth.authHeader = null
mockCloudAuth.isCloud = true
mockCloudAuth.authHeader = { Authorization: 'Bearer test-token' }
mockAxiosResponse(mockData)
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: { Authorization: 'Bearer test-token' }
})
)
})
})
})
describe('when distribution is not cloud', () => {
it('bypasses authentication for non-cloud environments', async () => {
const mockData = ['non-cloud data']
mockCloudAuth.isCloud = false
mockAxiosResponse(mockData)
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
const axiosCall = vi.mocked(axios.get).mock.calls[0][1]
expect(axiosCall).not.toHaveProperty('headers')
})
})
})
describe('auto-refresh on task completion', () => {
it('should add auto-refresh toggle widget', () => {
const mockNode = {
addWidget: vi.fn(),
widgets: []
}
const mockWidget = {
refresh: vi.fn()
}
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
widget: mockWidget as any
})
// Should add auto-refresh toggle widget
expect(mockNode.addWidget).toHaveBeenCalledWith(
'toggle',
'Auto-refresh after generation',
false,
expect.any(Function),
{
serialize: false
}
)
})
it('should register event listener when enabled', async () => {
const { api } = await import('@/scripts/api')
const addEventListenerSpy = vi.spyOn(api, 'addEventListener')
const mockNode = {
addWidget: vi.fn(),
widgets: []
}
const mockWidget = {
refresh: vi.fn()
}
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
widget: mockWidget as any
})
// Event listener should be registered immediately
expect(addEventListenerSpy).toHaveBeenCalledWith(
'execution_success',
expect.any(Function)
)
})
it('should refresh widget when workflow completes successfully', async () => {
const { api } = await import('@/scripts/api')
let executionSuccessHandler: (() => void) | undefined
vi.spyOn(api, 'addEventListener').mockImplementation((event, handler) => {
if (event === 'execution_success') {
executionSuccessHandler = handler as () => void
}
})
const mockNode = {
addWidget: vi.fn(),
widgets: []
}
const mockWidget = {} as any
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
widget: mockWidget
})
// Spy on the refresh function that was added by useRemoteWidget
const refreshSpy = vi.spyOn(mockWidget, 'refresh')
// Get the toggle callback and enable auto-refresh
const toggleCallback = mockNode.addWidget.mock.calls.find(
(call) => call[0] === 'toggle'
)?.[3]
toggleCallback?.(true)
// Simulate workflow completion
executionSuccessHandler?.()
expect(refreshSpy).toHaveBeenCalled()
})
it('should not refresh when toggle is disabled', async () => {
const { api } = await import('@/scripts/api')
let executionSuccessHandler: (() => void) | undefined
vi.spyOn(api, 'addEventListener').mockImplementation((event, handler) => {
if (event === 'execution_success') {
executionSuccessHandler = handler as () => void
}
})
const mockNode = {
addWidget: vi.fn(),
widgets: []
}
const mockWidget = {} as any
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
widget: mockWidget
})
// Spy on the refresh function that was added by useRemoteWidget
const refreshSpy = vi.spyOn(mockWidget, 'refresh')
// Toggle is disabled by default
// Simulate workflow completion
executionSuccessHandler?.()
expect(refreshSpy).not.toHaveBeenCalled()
})
it('should cleanup event listener on node removal', async () => {
const { api } = await import('@/scripts/api')
let executionSuccessHandler: (() => void) | undefined
vi.spyOn(api, 'addEventListener').mockImplementation((event, handler) => {
if (event === 'execution_success') {
executionSuccessHandler = handler as () => void
}
})
const removeEventListenerSpy = vi.spyOn(api, 'removeEventListener')
const mockNode = {
addWidget: vi.fn(),
widgets: [],
onRemoved: undefined as any
}
const mockWidget = {
refresh: vi.fn()
}
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
widget: mockWidget as any
})
// Simulate node removal
mockNode.onRemoved?.()
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'execution_success',
executionSuccessHandler
)
})
})
})

View File

@@ -0,0 +1,191 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
getComponent,
isEssential,
shouldRenderAsVue,
FOR_TESTING
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
const {
WidgetAudioUI,
WidgetButton,
WidgetColorPicker,
WidgetInputNumber,
WidgetInputText,
WidgetMarkdown,
WidgetSelect,
WidgetTextarea,
WidgetToggleSwitch
} = FOR_TESTING
vi.mock('@/stores/queueStore', () => ({
useQueueStore: vi.fn(() => ({
historyTasks: []
}))
}))
// Mock the settings store for components that might use it
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn(() => 'before')
})
}))
describe('widgetRegistry', () => {
beforeEach(() => {
setActivePinia(createTestingPinia())
vi.clearAllMocks()
})
describe('getComponent', () => {
// Test number type mappings
describe('number types', () => {
it('should map int types to slider widget', () => {
expect(getComponent('int', 'bar')).toBe(WidgetInputNumber)
expect(getComponent('INT', 'bar')).toBe(WidgetInputNumber)
})
it('should map float types to slider widget', () => {
expect(getComponent('float', 'cfg')).toBe(WidgetInputNumber)
expect(getComponent('FLOAT', 'cfg')).toBe(WidgetInputNumber)
expect(getComponent('number', 'cfg')).toBe(WidgetInputNumber)
expect(getComponent('slider', 'cfg')).toBe(WidgetInputNumber)
})
})
// Test text type mappings
describe('text types', () => {
it('should map text variations to input text widget', () => {
expect(getComponent('text', 'text')).toBe(WidgetInputText)
expect(getComponent('string', 'text')).toBe(WidgetInputText)
expect(getComponent('STRING', 'text')).toBe(WidgetInputText)
})
it('should map multiline text types to textarea widget', () => {
expect(getComponent('multiline', 'text')).toBe(WidgetTextarea)
expect(getComponent('textarea', 'text')).toBe(WidgetTextarea)
expect(getComponent('TEXTAREA', 'text')).toBe(WidgetTextarea)
expect(getComponent('customtext', 'text')).toBe(WidgetTextarea)
})
it('should map markdown to markdown widget', () => {
expect(getComponent('MARKDOWN', 'text')).toBe(WidgetMarkdown)
expect(getComponent('markdown', 'text')).toBe(WidgetMarkdown)
})
})
// Test selection type mappings
describe('selection types', () => {
it('should map combo types to select widget', () => {
expect(getComponent('combo', 'image')).toBe(WidgetSelect)
expect(getComponent('COMBO', 'video')).toBe(WidgetSelect)
})
})
// Test boolean type mappings
describe('boolean types', () => {
it('should map boolean types to toggle switch widget', () => {
expect(getComponent('toggle', 'image')).toBe(WidgetToggleSwitch)
expect(getComponent('boolean', 'image')).toBe(WidgetToggleSwitch)
expect(getComponent('BOOLEAN', 'image')).toBe(WidgetToggleSwitch)
})
})
// Test advanced widget mappings
describe('advanced widgets', () => {
it('should map color types to color picker widget', () => {
expect(getComponent('color', 'color')).toBe(WidgetColorPicker)
expect(getComponent('COLOR', 'color')).toBe(WidgetColorPicker)
})
it('should map button types to button widget', () => {
expect(getComponent('button', '')).toBe(WidgetButton)
expect(getComponent('BUTTON', '')).toBe(WidgetButton)
})
})
// Test fallback behavior
describe('fallback behavior', () => {
it('should return null for unknown types', () => {
expect(getComponent('unknown', 'unknown')).toBe(null)
expect(getComponent('custom_widget', 'custom_widget')).toBe(null)
expect(getComponent('', '')).toBe(null)
})
})
})
describe('shouldRenderAsVue', () => {
it('should return false for widgets marked as canvas-only', () => {
const widget = { type: 'text', options: { canvasOnly: true } }
expect(shouldRenderAsVue(widget)).toBe(false)
})
it('should return false for widgets without a type', () => {
const widget = { options: {} }
expect(shouldRenderAsVue(widget)).toBe(false)
})
it('should return true for widgets with mapped types', () => {
expect(shouldRenderAsVue({ type: 'text' })).toBe(true)
expect(shouldRenderAsVue({ type: 'int' })).toBe(true)
expect(shouldRenderAsVue({ type: 'combo' })).toBe(true)
})
it('should respect options while checking type', () => {
const widget: Partial<SafeWidgetData> = {
type: 'text',
options: { precision: 5 }
}
expect(shouldRenderAsVue(widget)).toBe(true)
})
})
describe('isEssential', () => {
it('should identify essential widget types', () => {
expect(isEssential('int')).toBe(true)
expect(isEssential('INT')).toBe(true)
expect(isEssential('float')).toBe(true)
expect(isEssential('FLOAT')).toBe(true)
expect(isEssential('boolean')).toBe(true)
expect(isEssential('BOOLEAN')).toBe(true)
expect(isEssential('combo')).toBe(true)
expect(isEssential('COMBO')).toBe(true)
})
it('should identify non-essential widget types', () => {
expect(isEssential('button')).toBe(false)
expect(isEssential('color')).toBe(false)
expect(isEssential('chart')).toBe(false)
expect(isEssential('fileupload')).toBe(false)
})
it('should return false for unknown types', () => {
expect(isEssential('unknown')).toBe(false)
expect(isEssential('')).toBe(false)
})
})
describe('edge cases', () => {
it('should handle widgets with empty options', () => {
const widget = { type: 'text', options: {} }
expect(shouldRenderAsVue(widget)).toBe(true)
})
it('should handle case sensitivity correctly through aliases', () => {
// Test that both lowercase and uppercase work
expect(getComponent('string', '')).toBe(WidgetInputText)
expect(getComponent('STRING', '')).toBe(WidgetInputText)
expect(getComponent('combo', '')).toBe(WidgetSelect)
expect(getComponent('COMBO', '')).toBe(WidgetSelect)
})
it('should handle combo additional widgets', () => {
// Test that both lowercase and uppercase work
expect(getComponent('combo', 'audio')).toBe(WidgetAudioUI)
expect(getComponent('combo', 'image')).toBe(WidgetSelect)
})
})
})