Compare commits

..

1 Commits

Author SHA1 Message Date
huang47
9719157a19 test: cover graph selection and menu composables 2026-06-30 22:37:00 -07:00
17 changed files with 1472 additions and 894 deletions

View File

@@ -1,79 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('runWhenGlobalIdle', () => {
beforeEach(() => {
vi.resetModules()
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('falls back to a timeout when idle callbacks are unavailable', async () => {
vi.useFakeTimers()
vi.stubGlobal('requestIdleCallback', undefined)
vi.stubGlobal('cancelIdleCallback', undefined)
const { runWhenGlobalIdle } = await import('./async')
const runner = vi.fn()
const disposable = runWhenGlobalIdle(runner)
await vi.runAllTimersAsync()
expect(runner).toHaveBeenCalledOnce()
const deadline = runner.mock.calls[0][0]
expect(deadline.didTimeout).toBe(true)
expect(deadline.timeRemaining()).toBeGreaterThanOrEqual(0)
disposable.dispose()
disposable.dispose()
})
it('cancels fallback idle work before it runs', async () => {
vi.useFakeTimers()
vi.stubGlobal('requestIdleCallback', undefined)
vi.stubGlobal('cancelIdleCallback', undefined)
const { runWhenGlobalIdle } = await import('./async')
const runner = vi.fn()
runWhenGlobalIdle(runner).dispose()
await vi.runAllTimersAsync()
expect(runner).not.toHaveBeenCalled()
})
it('uses native idle callbacks when available', async () => {
const requestIdleCallback = vi.fn(() => 42)
const cancelIdleCallback = vi.fn()
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
vi.stubGlobal('cancelIdleCallback', cancelIdleCallback)
const { runWhenGlobalIdle } = await import('./async')
const runner = vi.fn()
const disposable = runWhenGlobalIdle(runner, 250)
expect(requestIdleCallback).toHaveBeenCalledWith(runner, { timeout: 250 })
disposable.dispose()
disposable.dispose()
expect(cancelIdleCallback).toHaveBeenCalledOnce()
expect(cancelIdleCallback).toHaveBeenCalledWith(42)
})
it('omits native idle timeout options when no timeout is supplied', async () => {
const requestIdleCallback = vi.fn(
(_cb: IdleRequestCallback, _options?: IdleRequestOptions) => 7
)
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
vi.stubGlobal('cancelIdleCallback', vi.fn())
const { runWhenGlobalIdle } = await import('./async')
const runner = vi.fn()
runWhenGlobalIdle(runner)
expect(requestIdleCallback).toHaveBeenCalledOnce()
expect(requestIdleCallback.mock.calls[0][0]).toBe(runner)
expect(requestIdleCallback.mock.calls[0][1]).toBeUndefined()
})
})

View File

@@ -4,7 +4,6 @@ import {
CREDITS_PER_USD,
COMFY_CREDIT_RATE_CENTS,
centsToCredits,
clampUsd,
creditsToCents,
creditsToUsd,
formatCredits,
@@ -44,23 +43,4 @@ describe('comfyCredits helpers', () => {
expect(formatCreditsFromUsd({ usd: 1, locale })).toBe('211.00')
expect(formatUsd({ value: 4.2, locale })).toBe('4.20')
})
test('recovers from incompatible fraction digit bounds', () => {
// {min:3,max:1} collapses to one fraction digit ('12.3'); the default {2,2}
// would yield '12.35', so this distinguishes recovery from options ignored.
expect(
formatCredits({
value: 12.345,
locale: 'en-US',
numberOptions: { minimumFractionDigits: 3, maximumFractionDigits: 1 }
})
).toBe('12.3')
})
test('clamps USD purchase values into the supported range', () => {
expect(clampUsd(Number.NaN)).toBe(0)
expect(clampUsd(-5)).toBe(1)
expect(clampUsd(42)).toBe(42)
expect(clampUsd(5000)).toBe(1000)
})
})

View File

@@ -0,0 +1,217 @@
import type * as VueI18n from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphGroup } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useGroupMenuOptions } from '@/composables/graph/useGroupMenuOptions'
const { canvas, captureCanvasState, isLightTheme, refreshCanvas, settings } =
vi.hoisted(() => ({
canvas: { setDirty: vi.fn() },
captureCanvasState: vi.fn(),
isLightTheme: { value: false },
refreshCanvas: vi.fn(),
settings: { 'Comfy.GroupSelectedNodes.Padding': 10 } as Record<
string,
unknown
>
}))
vi.mock('vue-i18n', async (importOriginal) => ({
...(await importOriginal<typeof VueI18n>()),
useI18n: () => ({ t: (key: string) => key })
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: (k: string) => settings[k] })
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: { changeTracker: { captureCanvasState } }
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas })
}))
vi.mock('@/composables/graph/useCanvasRefresh', () => ({
useCanvasRefresh: () => ({ refreshCanvas })
}))
vi.mock('@/composables/graph/useNodeCustomization', () => ({
useNodeCustomization: () => ({
shapeOptions: [{ value: 1, localizedName: 'Box' }],
colorOptions: [
{ value: { dark: '#111', light: '#eee' }, localizedName: 'Red' }
],
isLightTheme
})
}))
function group(over: Record<string, unknown> = {}): LGraphGroup {
return {
recomputeInsideNodes: vi.fn(),
resizeTo: vi.fn(),
children: [],
graph: { change: vi.fn() },
nodes: [],
...over
} as unknown as LGraphGroup
}
beforeEach(() => {
canvas.setDirty.mockReset()
captureCanvasState.mockReset()
isLightTheme.value = false
refreshCanvas.mockReset()
})
describe('useGroupMenuOptions', () => {
it('fits a group to its nodes, resizing with the configured padding', () => {
const g = group()
useGroupMenuOptions().getFitGroupToNodesOption(g).action?.()
expect(g.recomputeInsideNodes).toHaveBeenCalled()
expect(g.resizeTo).toHaveBeenCalledWith(g.children, 10)
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('aborts the fit action when recompute throws', () => {
const g = group({
recomputeInsideNodes: vi.fn(() => {
throw new Error('boom')
})
})
useGroupMenuOptions().getFitGroupToNodesOption(g).action?.()
expect(g.resizeTo).not.toHaveBeenCalled()
})
it('applies a shape to all group nodes via the shape submenu', () => {
const node = { shape: 0, mode: LGraphEventMode.ALWAYS }
const bump = vi.fn()
const option = useGroupMenuOptions().getGroupShapeOptions(
group({ nodes: [node] }),
bump
)
option.submenu?.[0].action?.()
expect(node.shape).toBe(1)
expect(refreshCanvas).toHaveBeenCalled()
expect(bump).toHaveBeenCalled()
})
it('handles shape actions when a group has no nodes array', () => {
const bump = vi.fn()
useGroupMenuOptions()
.getGroupShapeOptions(group({ nodes: undefined }), bump)
.submenu?.[0].action?.()
expect(refreshCanvas).toHaveBeenCalled()
expect(bump).toHaveBeenCalled()
})
it('applies a color to the group via the color submenu (dark theme)', () => {
const g = group()
const bump = vi.fn()
useGroupMenuOptions().getGroupColorOptions(g, bump).submenu?.[0].action?.()
expect((g as unknown as { color: string }).color).toBe('#111')
expect(bump).toHaveBeenCalled()
})
it('applies a light-theme color to the group via the color submenu', () => {
const g = group()
const bump = vi.fn()
isLightTheme.value = true
useGroupMenuOptions().getGroupColorOptions(g, bump).submenu?.[0].action?.()
expect((g as unknown as { color: string }).color).toBe('#eee')
expect(bump).toHaveBeenCalled()
})
it('returns no mode options for an empty group', () => {
expect(useGroupMenuOptions().getGroupModeOptions(group(), vi.fn())).toEqual(
[]
)
})
it('returns no mode options when a group has no nodes array', () => {
expect(
useGroupMenuOptions().getGroupModeOptions(
group({ nodes: undefined }),
vi.fn()
)
).toEqual([])
})
it('returns no mode options when recomputing group nodes fails', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const options = useGroupMenuOptions().getGroupModeOptions(
group({
recomputeInsideNodes: vi.fn(() => {
throw new Error('boom')
})
}),
vi.fn()
)
expect(options).toEqual([])
expect(warnSpy).toHaveBeenCalledWith(
'Failed to recompute nodes in group for mode options:',
expect.any(Error)
)
warnSpy.mockRestore()
})
it('builds mode options for uniform nodes and applies the new mode', () => {
const node = { shape: 0, mode: LGraphEventMode.ALWAYS }
const bump = vi.fn()
const options = useGroupMenuOptions().getGroupModeOptions(
group({ nodes: [node] }),
bump
)
expect(options.length).toBeGreaterThan(0)
options[0].action?.()
expect(node.mode).not.toBe(LGraphEventMode.ALWAYS)
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(bump).toHaveBeenCalled()
})
it('offers two alternate modes when all nodes are NEVER', () => {
const options = useGroupMenuOptions().getGroupModeOptions(
group({ nodes: [{ mode: LGraphEventMode.NEVER }] }),
vi.fn()
)
expect(options).toHaveLength(2)
})
it('offers two alternate modes when all nodes are BYPASS', () => {
const options = useGroupMenuOptions().getGroupModeOptions(
group({ nodes: [{ mode: LGraphEventMode.BYPASS }] }),
vi.fn()
)
expect(options).toHaveLength(2)
})
it('offers all three modes when nodes have mixed modes', () => {
const options = useGroupMenuOptions().getGroupModeOptions(
group({
nodes: [
{ mode: LGraphEventMode.ALWAYS },
{ mode: LGraphEventMode.NEVER }
]
}),
vi.fn()
)
expect(options).toHaveLength(3)
})
it('offers all three modes when the uniform mode is unknown', () => {
const options = useGroupMenuOptions().getGroupModeOptions(
group({ nodes: [{ mode: 999 }] }),
vi.fn()
)
expect(options).toHaveLength(3)
})
})

View File

@@ -0,0 +1,294 @@
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphGroup } from '@/lib/litegraph/src/litegraph'
import {
isNodeOptionsOpen,
registerNodeOptionsInstance,
showNodeOptions,
toggleNodeOptions,
useMoreOptionsMenu
} from '@/composables/graph/useMoreOptionsMenu'
const {
canvasState,
extraWidgetOptions,
imageOptions,
nodeMenu,
selectionMenu,
selectionState
} = vi.hoisted(() => ({
canvasState: {
canvas: undefined as
| undefined
| {
getNodeMenuOptions: ReturnType<typeof vi.fn>
}
},
extraWidgetOptions: {
value: [] as Array<{ content: string; callback?: () => void }>
},
imageOptions: {
value: [] as Array<{ label: string; hasSubmenu?: boolean; submenu?: [] }>
},
nodeMenu: {
visualOptions: {
value: [] as Array<{
label: string
hasSubmenu?: boolean
submenu?: Array<{ label: string; action: () => void }>
}>
}
},
selectionMenu: {
basicOptions: { value: [{ label: 'Copy' }] },
multipleOptions: { value: [{ label: 'Align' }] },
subgraphOptions: { value: [] as Array<{ label: string }> }
},
selectionState: {
selectedItems: { value: [] as unknown[] },
selectedNodes: { value: [] as unknown[] },
canOpenNodeInfo: { value: false },
openNodeInfo: vi.fn(() => true),
hasSubgraphs: { value: false },
hasImageNode: { value: false },
hasOutputNodesSelected: { value: false },
hasMultipleSelection: { value: false },
computeSelectionFlags: vi.fn(() => ({
collapsed: false,
pinned: false
}))
}
}))
vi.mock('@/composables/graph/useSelectionState', () => ({
useSelectionState: () => selectionState
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => canvasState
}))
vi.mock('@/services/litegraphService', () => ({
getExtraOptionsForWidget: () => extraWidgetOptions.value
}))
vi.mock('@/composables/graph/useImageMenuOptions', () => ({
useImageMenuOptions: () => ({
getImageMenuOptions: () => imageOptions.value
})
}))
vi.mock('@/composables/graph/useNodeMenuOptions', () => ({
useNodeMenuOptions: () => ({
getNodeInfoOption: (openNodeInfo: () => boolean) => ({
label: 'Node Info',
action: openNodeInfo
}),
getNodeVisualOptions: () => nodeMenu.visualOptions.value,
getPinOption: () => ({ label: 'Pin' }),
getBypassOption: () => ({ label: 'Bypass' }),
getRunBranchOption: () => ({ label: 'Run Branch' })
})
}))
vi.mock('@/composables/graph/useGroupMenuOptions', () => ({
useGroupMenuOptions: () => ({
getFitGroupToNodesOption: () => ({ label: 'Fit' }),
getGroupColorOptions: () => ({ label: 'Group Color' }),
getGroupModeOptions: () => [{ label: 'Group Mode' }]
})
}))
vi.mock('@/composables/graph/useSelectionMenuOptions', () => ({
useSelectionMenuOptions: () => ({
getBasicSelectionOptions: () => selectionMenu.basicOptions.value,
getMultipleNodesOptions: () => selectionMenu.multipleOptions.value,
getSubgraphOptions: () => selectionMenu.subgraphOptions.value
})
}))
beforeEach(() => {
vi.clearAllMocks()
registerNodeOptionsInstance(null)
canvasState.canvas = undefined
extraWidgetOptions.value = []
imageOptions.value = []
nodeMenu.visualOptions.value = []
selectionMenu.basicOptions.value = [{ label: 'Copy' }]
selectionMenu.multipleOptions.value = [{ label: 'Align' }]
selectionMenu.subgraphOptions.value = []
selectionState.selectedItems.value = []
selectionState.selectedNodes.value = []
selectionState.canOpenNodeInfo.value = false
selectionState.hasSubgraphs.value = false
selectionState.hasImageNode.value = false
selectionState.hasOutputNodesSelected.value = false
selectionState.hasMultipleSelection.value = false
selectionState.computeSelectionFlags.mockReturnValue({
collapsed: false,
pinned: false
})
})
function labels() {
return useMoreOptionsMenu()
.menuOptions.value.map((o) => o.label)
.filter(Boolean)
}
describe('node options popover instance', () => {
it('reports closed when no instance is registered', () => {
expect(isNodeOptionsOpen()).toBe(false)
})
it('reflects the registered instance open state and forwards toggle/show', () => {
const toggle = vi.fn()
const show = vi.fn()
registerNodeOptionsInstance({
toggle,
show,
hide: vi.fn(),
isOpen: ref(true)
})
expect(isNodeOptionsOpen()).toBe(true)
toggleNodeOptions(new Event('click'))
showNodeOptions(new MouseEvent('contextmenu'))
expect(toggle).toHaveBeenCalled()
expect(show).toHaveBeenCalled()
})
})
describe('useMoreOptionsMenu', () => {
it('assembles a non-empty menu for a single selected node', () => {
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
expect(labels()).toContain('Copy')
expect(labels()).toContain('Pin')
})
it('includes run-branch and multiple-node options for output selections', () => {
const nodes = [
{ id: 1, widgets: [] },
{ id: 2, widgets: [] }
]
selectionState.selectedItems.value = nodes
selectionState.selectedNodes.value = nodes
selectionState.hasOutputNodesSelected.value = true
selectionState.hasMultipleSelection.value = true
const menuLabels = labels()
expect(menuLabels).toContain('Run Branch')
expect(menuLabels).toContain('Align')
})
it('recomputes menu flags after a manual bump', () => {
const { bump, menuOptions } = useMoreOptionsMenu()
void menuOptions.value
expect(selectionState.computeSelectionFlags).toHaveBeenCalledTimes(1)
bump()
void menuOptions.value
expect(selectionState.computeSelectionFlags).toHaveBeenCalledTimes(2)
})
it('assembles group-context options for a single selected group', () => {
const group = new LGraphGroup('Group')
selectionState.selectedItems.value = [group]
selectionState.selectedNodes.value = []
const menuLabels = labels()
expect(menuLabels).toContain('Group Mode')
expect(menuLabels).toContain('Fit')
expect(menuLabels).toContain('Group Color')
})
it('includes node info and visual options for a single node', () => {
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
selectionState.canOpenNodeInfo.value = true
nodeMenu.visualOptions.value = [
{ label: 'Minimize Node' },
{ label: 'Shape', hasSubmenu: true, submenu: [] },
{ label: 'Color', hasSubmenu: true, submenu: [] }
]
const menu = useMoreOptionsMenu().menuOptions.value
expect(menu.map((o) => o.label)).toEqual(
expect.arrayContaining(['Node Info', 'Minimize Node', 'Shape', 'Color'])
)
menu.find((o) => o.label === 'Node Info')?.action?.()
expect(selectionState.openNodeInfo).toHaveBeenCalled()
})
it('returns only entries that have populated submenus', () => {
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
nodeMenu.visualOptions.value = [
{ label: 'Minimize Node' },
{
label: 'Shape',
hasSubmenu: true,
submenu: [{ label: 'Box', action: vi.fn() }]
},
{ label: 'Color', hasSubmenu: true }
]
expect(
useMoreOptionsMenu().menuOptionsWithSubmenu.value.map((o) => o.label)
).toEqual(['Shape'])
})
it('includes image menu options for a selected image node', () => {
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
selectionState.hasImageNode.value = true
imageOptions.value = [{ label: 'Open Image' }]
expect(labels()).toContain('Open Image')
})
it('merges LiteGraph menu options for a single selected node', () => {
const node = { id: 1, widgets: [] }
const getNodeMenuOptions = vi.fn(() => [
{ content: 'Extension Action', callback: vi.fn() }
])
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
canvasState.canvas = { getNodeMenuOptions }
expect(labels()).toContain('Extension Action')
expect(getNodeMenuOptions).toHaveBeenCalledWith(node)
})
it('keeps Vue options when LiteGraph menu construction throws', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
canvasState.canvas = {
getNodeMenuOptions: vi.fn(() => {
throw new Error('boom')
})
}
expect(labels()).toContain('Copy')
expect(errorSpy).toHaveBeenCalledWith(
'Error getting LiteGraph menu items:',
expect.any(Error)
)
errorSpy.mockRestore()
})
it('adds hovered widget options to the selected node menu', () => {
const node = { id: 1, widgets: [{ name: 'image' }] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
extraWidgetOptions.value = [{ content: 'Widget Extra', callback: vi.fn() }]
showNodeOptions(new MouseEvent('contextmenu'), 'image')
expect(labels()).toContain('Widget Extra')
})
})

View File

@@ -0,0 +1,175 @@
import type * as VueI18n from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodeCustomization } from '@/composables/graph/useNodeCustomization'
const { selection, refreshCanvas, palette } = vi.hoisted(() => ({
selection: { items: [] as unknown[] },
refreshCanvas: vi.fn(),
palette: { light_theme: false }
}))
vi.mock('vue-i18n', async (importOriginal) => ({
...(await importOriginal<typeof VueI18n>()),
useI18n: () => ({ t: (key: string) => key })
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
get selectedItems() {
return selection.items
}
})
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: () => ({
get completedActivePalette() {
return { light_theme: palette.light_theme }
}
})
}))
vi.mock('@/composables/graph/useCanvasRefresh', () => ({
useCanvasRefresh: () => ({ refreshCanvas })
}))
function colorable(bgcolor?: string) {
return {
setColorOption: vi.fn(),
getColorOption: () => (bgcolor ? { bgcolor } : null)
}
}
beforeEach(() => {
selection.items = []
refreshCanvas.mockReset()
palette.light_theme = false
})
describe('useNodeCustomization', () => {
it('exposes color and shape option lists', () => {
const { colorOptions, shapeOptions } = useNodeCustomization()
expect(colorOptions.length).toBeGreaterThan(1)
expect(shapeOptions.length).toBeGreaterThan(0)
})
it('reflects the active palette light-theme flag', () => {
palette.light_theme = true
expect(useNodeCustomization().isLightTheme.value).toBe(true)
})
it('clears color on all colorable items for the no-color option', () => {
const item = colorable()
selection.items = [item]
useNodeCustomization().applyColor(null)
expect(item.setColorOption).toHaveBeenCalledWith(null)
expect(refreshCanvas).toHaveBeenCalled()
})
it('applies a named color option to colorable items', () => {
const item = colorable()
selection.items = [item]
const { colorOptions, applyColor } = useNodeCustomization()
const named = colorOptions.at(-1)!
applyColor(named)
expect(item.setColorOption).toHaveBeenCalledTimes(1)
expect(item.setColorOption.mock.calls[0][0]).not.toBeNull()
})
it('skips non-colorable items when applying colors', () => {
const item = colorable()
selection.items = [{}, item]
useNodeCustomization().applyColor(null)
expect(item.setColorOption).toHaveBeenCalledWith(null)
expect(refreshCanvas).toHaveBeenCalled()
})
it('returns null current color for an empty selection', () => {
expect(useNodeCustomization().getCurrentColor()).toBeNull()
})
it('returns null current color when no selected item is colorable', () => {
selection.items = [{}]
expect(useNodeCustomization().getCurrentColor()).toBeNull()
})
it('reports a recognized current color', () => {
const { colorOptions, getCurrentColor } = useNodeCustomization()
const named = colorOptions.at(-1)!
selection.items = [colorable(named.value.dark)]
expect(getCurrentColor()?.name).toBe(named.name)
})
it('falls back to the no-color option for an unrecognized current color', () => {
selection.items = [colorable('#not-a-known-color')]
const result = useNodeCustomization().getCurrentColor()
expect(result?.name).toBe('noColor')
})
it('no-ops shape changes when no graph nodes are selected', () => {
selection.items = [colorable()]
const { applyShape, shapeOptions } = useNodeCustomization()
applyShape(shapeOptions[0])
expect(refreshCanvas).not.toHaveBeenCalled()
})
it('returns null current shape with no nodes selected', () => {
expect(useNodeCustomization().getCurrentShape()).toBeNull()
})
it('applies a shape to selected graph nodes and refreshes', () => {
const node = new LGraphNode('Test')
selection.items = [node]
const { applyShape, shapeOptions } = useNodeCustomization()
const target = shapeOptions[0]
applyShape(target)
expect(node.shape).toBe(target.value)
expect(refreshCanvas).toHaveBeenCalled()
})
it('reports the current shape of a selected node', () => {
const node = new LGraphNode('Test')
const { shapeOptions, getCurrentShape } = useNodeCustomization()
node.shape = shapeOptions[0].value
selection.items = [node]
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
})
it('uses the default shape when a selected node has no shape', () => {
const node = new LGraphNode('Test')
Object.defineProperty(node, 'shape', {
value: undefined,
writable: true,
configurable: true
})
const { shapeOptions, getCurrentShape } = useNodeCustomization()
selection.items = [node]
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
})
it('falls back to the default shape for an unknown node shape', () => {
const node = new LGraphNode('Test')
Object.defineProperty(node, 'shape', {
value: 999,
writable: true,
configurable: true
})
const { shapeOptions, getCurrentShape } = useNodeCustomization()
selection.items = [node]
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
})
})

View File

@@ -10,30 +10,43 @@ import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { toNodeId } from '@/types/nodeId'
// canvasStore transitively imports the app singleton; stub it so the real
// ComfyApp module never loads during these unit tests.
const { actions, customization } = vi.hoisted(() => ({
actions: {
adjustNodeSize: vi.fn(),
toggleNodeCollapse: vi.fn(),
toggleNodePin: vi.fn(),
toggleNodeBypass: vi.fn(),
runBranch: vi.fn()
},
customization: {
shapeOptions: [] as Array<{ localizedName: string; value: string }>,
colorOptions: [] as Array<{
name: string
localizedName: string
value: { dark: string; light: string }
}>,
applyShape: vi.fn(),
applyColor: vi.fn(),
isLightTheme: { value: false }
}
}))
vi.mock('@/scripts/app', () => ({
app: { canvas: { selected_nodes: null } }
}))
vi.mock('@/composables/graph/useNodeCustomization', () => ({
useNodeCustomization: () => ({
shapeOptions: [],
applyShape: vi.fn(),
applyColor: vi.fn(),
colorOptions: [],
isLightTheme: { value: false }
shapeOptions: customization.shapeOptions,
applyShape: customization.applyShape,
applyColor: customization.applyColor,
colorOptions: customization.colorOptions,
isLightTheme: customization.isLightTheme
})
}))
vi.mock('@/composables/graph/useSelectedNodeActions', () => ({
useSelectedNodeActions: () => ({
adjustNodeSize: vi.fn(),
toggleNodeCollapse: vi.fn(),
toggleNodePin: vi.fn(),
toggleNodeBypass: vi.fn(),
runBranch: vi.fn()
})
useSelectedNodeActions: () => actions
}))
const i18n = createI18n({
@@ -69,9 +82,29 @@ const getBypassLabel = (selected: LGraphNode[]): string => {
return label
}
describe('useNodeMenuOptions.getBypassOption', () => {
function readNodeMenuOptions<T>(
read: (options: ReturnType<typeof useNodeMenuOptions>) => T
): T {
const unread = Symbol('unread')
const result: { value: T | typeof unread } = { value: unread }
const Wrapper = defineComponent({
setup() {
result.value = read(useNodeMenuOptions())
return () => null
}
})
render(Wrapper, { global: { plugins: [i18n] } })
if (result.value === unread) throw new Error('Composable was not read')
return result.value
}
describe('useNodeMenuOptions', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
customization.shapeOptions = []
customization.colorOptions = []
customization.isLightTheme.value = false
})
it('labels as "Bypass" when no node is bypassed', () => {
@@ -97,4 +130,109 @@ describe('useNodeMenuOptions.getBypassOption', () => {
])
).toBe('contextMenu.Bypass')
})
it('labels visual node options from the collapsed state and bumps after action', () => {
const expandBump = vi.fn()
const expand = readNodeMenuOptions(
({ getNodeVisualOptions }) =>
getNodeVisualOptions({ collapsed: true, pinned: false }, expandBump)[0]
)
expect(expand).toMatchObject({
label: 'contextMenu.Expand Node',
icon: 'icon-[lucide--maximize-2]'
})
expand.action?.()
expect(actions.toggleNodeCollapse).toHaveBeenCalledTimes(1)
expect(expandBump).toHaveBeenCalledTimes(1)
const minimize = readNodeMenuOptions(
({ getNodeVisualOptions }) =>
getNodeVisualOptions({ collapsed: false, pinned: false }, vi.fn())[0]
)
expect(minimize).toMatchObject({
label: 'contextMenu.Minimize Node',
icon: 'icon-[lucide--minimize-2]'
})
})
it('labels pin options from the pinned state and bumps after action', () => {
const bump = vi.fn()
const unpin = readNodeMenuOptions(({ getPinOption }) =>
getPinOption({ collapsed: false, pinned: true }, bump)
)
expect(unpin).toMatchObject({
label: 'contextMenu.Unpin',
icon: 'icon-[lucide--pin-off]'
})
unpin.action?.()
expect(actions.toggleNodePin).toHaveBeenCalledTimes(1)
expect(bump).toHaveBeenCalledTimes(1)
const pin = readNodeMenuOptions(({ getPinOption }) =>
getPinOption({ collapsed: false, pinned: false }, vi.fn())
)
expect(pin).toMatchObject({
label: 'contextMenu.Pin',
icon: 'icon-[lucide--pin]'
})
})
it('builds shape and color submenus and applies selected values', () => {
customization.shapeOptions = [{ localizedName: 'Box', value: 'box' }]
customization.colorOptions = [
{
name: 'noColor',
localizedName: 'No Color',
value: { dark: '#000', light: '#fff' }
},
{
name: 'red',
localizedName: 'Red',
value: { dark: '#111', light: '#eee' }
}
]
const { visualOptions, colorSubmenu } = readNodeMenuOptions((options) => ({
visualOptions: options.getNodeVisualOptions(
{ collapsed: false, pinned: false },
vi.fn()
),
colorSubmenu: options.colorSubmenu.value
}))
expect(visualOptions[1].submenu).toEqual([
expect.objectContaining({ label: 'Box' })
])
visualOptions[1].submenu?.[0].action()
expect(customization.applyShape).toHaveBeenCalledWith(
customization.shapeOptions[0]
)
expect(colorSubmenu).toEqual([
expect.objectContaining({ label: 'No Color', color: '#000' }),
expect.objectContaining({ label: 'Red', color: '#111' })
])
colorSubmenu[0].action()
colorSubmenu[1].action()
expect(customization.applyColor).toHaveBeenNthCalledWith(1, null)
expect(customization.applyColor).toHaveBeenNthCalledWith(
2,
customization.colorOptions[1]
)
})
it('uses light-theme colors for the color submenu', () => {
customization.isLightTheme.value = true
customization.colorOptions = [
{
name: 'red',
localizedName: 'Red',
value: { dark: '#111', light: '#eee' }
}
]
expect(
readNodeMenuOptions((options) => options.colorSubmenu.value[0].color)
).toBe('#eee')
})
})

View File

@@ -0,0 +1,238 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSelectionOperations } from '@/composables/graph/useSelectionOperations'
const {
canvas,
toastAdd,
captureCanvasState,
updateSelectedItems,
prompt,
titleEditor,
store
} = vi.hoisted(() => ({
canvas: {
selectedItems: new Set<unknown>(),
copyToClipboard: vi.fn(),
pasteFromClipboard: vi.fn(),
deleteSelected: vi.fn(),
setDirty: vi.fn()
},
toastAdd: vi.fn(),
captureCanvasState: vi.fn(),
updateSelectedItems: vi.fn(),
prompt: vi.fn(),
titleEditor: { titleEditorTarget: null as unknown },
store: { selectedItems: [] as unknown[] }
}))
vi.mock('@/scripts/app', () => ({ app: { canvas } }))
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: toastAdd })
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: { changeTracker: { captureCanvasState } }
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
updateSelectedItems,
get selectedItems() {
return store.selectedItems
}
}),
useTitleEditorStore: () => titleEditor
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({ prompt })
}))
beforeEach(() => {
canvas.selectedItems = new Set()
canvas.copyToClipboard.mockReset()
canvas.pasteFromClipboard.mockReset()
canvas.deleteSelected.mockReset()
canvas.setDirty.mockReset()
toastAdd.mockReset()
captureCanvasState.mockReset()
updateSelectedItems.mockReset()
prompt.mockReset()
titleEditor.titleEditorTarget = null
store.selectedItems = []
})
describe('useSelectionOperations', () => {
it('warns and does nothing when copying an empty selection', () => {
useSelectionOperations().copySelection()
expect(canvas.copyToClipboard).not.toHaveBeenCalled()
expect(toastAdd).toHaveBeenCalledWith({
severity: 'warn',
summary: 'g.nothingToCopy',
detail: 'g.selectItemsToCopy',
life: 3000
})
})
it('copies a non-empty selection and reports success', () => {
canvas.selectedItems = new Set(['a'])
useSelectionOperations().copySelection()
expect(canvas.copyToClipboard).toHaveBeenCalled()
expect(toastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'g.copied',
detail: 'g.itemsCopiedToClipboard',
life: 2000
})
})
it('pastes from clipboard and captures canvas state', () => {
useSelectionOperations().pasteSelection()
expect(canvas.pasteFromClipboard).toHaveBeenCalledWith({
connectInputs: false
})
expect(captureCanvasState).toHaveBeenCalled()
})
it('duplicates by copy, clear, paste', () => {
canvas.selectedItems = new Set(['a'])
useSelectionOperations().duplicateSelection()
expect(canvas.copyToClipboard).toHaveBeenCalled()
expect(canvas.selectedItems.size).toBe(0)
expect(updateSelectedItems).toHaveBeenCalled()
expect(canvas.pasteFromClipboard).toHaveBeenCalledWith({
connectInputs: false
})
expect(captureCanvasState).toHaveBeenCalled()
})
it('warns when duplicating nothing', () => {
useSelectionOperations().duplicateSelection()
expect(canvas.copyToClipboard).not.toHaveBeenCalled()
expect(toastAdd).toHaveBeenCalledWith({
severity: 'warn',
summary: 'g.nothingToDuplicate',
detail: 'g.selectItemsToDuplicate',
life: 3000
})
})
it('deletes a non-empty selection and marks the canvas dirty', () => {
canvas.selectedItems = new Set(['a'])
useSelectionOperations().deleteSelection()
expect(canvas.deleteSelected).toHaveBeenCalled()
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('warns when deleting nothing', () => {
useSelectionOperations().deleteSelection()
expect(canvas.deleteSelected).not.toHaveBeenCalled()
expect(toastAdd).toHaveBeenCalledWith({
severity: 'warn',
summary: 'g.nothingToDelete',
detail: 'g.selectItemsToDelete',
life: 3000
})
})
it('routes a single node rename to the title editor', async () => {
const node = new LGraphNode('Test')
store.selectedItems = [node]
await useSelectionOperations().renameSelection()
expect(titleEditor.titleEditorTarget).toBe(node)
expect(prompt).not.toHaveBeenCalled()
})
it('renames a single non-node item via the prompt dialog', async () => {
const group = { title: 'Old' }
store.selectedItems = [group]
prompt.mockResolvedValue('New')
await useSelectionOperations().renameSelection()
expect(group.title).toBe('New')
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('leaves a single titled item unchanged when the prompt returns the same title', async () => {
const group = { title: 'Old' }
store.selectedItems = [group]
prompt.mockResolvedValue('Old')
await useSelectionOperations().renameSelection()
expect(group.title).toBe('Old')
expect(canvas.setDirty).not.toHaveBeenCalled()
expect(captureCanvasState).not.toHaveBeenCalled()
})
it('does not assign a title to a selected item without a title property', async () => {
const item = {}
store.selectedItems = [item]
prompt.mockResolvedValue('New')
await useSelectionOperations().renameSelection()
expect(item).toEqual({})
expect(canvas.setDirty).not.toHaveBeenCalled()
expect(captureCanvasState).not.toHaveBeenCalled()
})
it('batch-renames multiple items with an indexed base name', async () => {
const a = { title: 'a' }
const b = { title: 'b' }
store.selectedItems = [a, b]
prompt.mockResolvedValue('Item')
await useSelectionOperations().renameSelection()
expect(a.title).toBe('Item 1')
expect(b.title).toBe('Item 2')
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('skips untitled items during batch rename', async () => {
const a = { title: 'a' }
const b = {}
store.selectedItems = [a, b]
prompt.mockResolvedValue('Item')
await useSelectionOperations().renameSelection()
expect(a.title).toBe('Item 1')
expect(b).toEqual({})
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('leaves a multiple selection unchanged when batch rename is cancelled', async () => {
const a = { title: 'a' }
const b = { title: 'b' }
store.selectedItems = [a, b]
prompt.mockResolvedValue('')
await useSelectionOperations().renameSelection()
expect(a.title).toBe('a')
expect(b.title).toBe('b')
expect(canvas.setDirty).not.toHaveBeenCalled()
expect(captureCanvasState).not.toHaveBeenCalled()
})
it('warns when renaming an empty selection', async () => {
await useSelectionOperations().renameSelection()
expect(toastAdd).toHaveBeenCalledWith({
severity: 'warn',
summary: 'g.nothingToRename',
detail: 'g.selectItemsToRename',
life: 3000
})
})
})

View File

@@ -8,7 +8,12 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
import {
isImageNode,
isLGraphGroup,
isLGraphNode,
isLoad3dNode
} from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
import {
createMockLGraphNode,
@@ -17,7 +22,9 @@ import {
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(),
isImageNode: vi.fn()
isImageNode: vi.fn(),
isLGraphGroup: vi.fn(),
isLoad3dNode: vi.fn()
}))
vi.mock('@/utils/nodeFilterUtil', () => ({
@@ -96,6 +103,16 @@ describe('useSelectionState', () => {
const typedNode = node as { type?: string }
return typedNode?.type === 'ImageNode'
})
vi.mocked(isLGraphGroup).mockImplementation((item: unknown) => {
const typedItem = item as { isGroup?: boolean }
return typedItem?.isGroup === true
})
vi.mocked(isLoad3dNode).mockImplementation((node: unknown) => {
const typedNode = node as { type?: string }
return (
typedNode?.type === 'Load3D' || typedNode?.type === 'Load3DAnimation'
)
})
vi.mocked(filterOutputNodes).mockImplementation((nodes) =>
nodes.filter((n) => n.type === 'OutputNode')
)
@@ -135,6 +152,21 @@ describe('useSelectionState', () => {
const { hasMultipleSelection } = useSelectionState()
expect(hasMultipleSelection.value).toBe(false)
})
test('hasGroupedNodesSelection should detect a group containing nodes', () => {
const canvasStore = useCanvasStore()
const graphNode = createMockLGraphNode({ id: 2 })
const group = createMockPositionable({ id: 2000 })
Object.assign(group, {
isGroup: true,
isNode: false,
children: new Set([graphNode])
})
canvasStore.$state.selectedItems = [group]
const { hasGroupedNodesSelection } = useSelectionState()
expect(hasGroupedNodesSelection.value).toBe(true)
})
})
describe('Node Type Filtering', () => {
@@ -215,6 +247,13 @@ describe('useSelectionState', () => {
const newIsPinned = newSelectedNodes.value.some((n) => n.pinned === true)
expect(newIsPinned).toBe(false)
})
test('should compute default flags for an empty node selection', () => {
expect(useSelectionState().computeSelectionFlags()).toEqual({
collapsed: false,
pinned: false
})
})
})
describe('Node Info', () => {

View File

@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits'
import {
@@ -12,6 +14,7 @@ import {
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDef, PriceBadge } from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { toNodeId } from '@/types/nodeId'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
@@ -123,6 +126,47 @@ function createMockNode(
})
}
async function flushMacrotask(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0))
}
async function resolveDisplayPrice(
node: LGraphNode,
widgetOverrides?: ReadonlyMap<string, unknown>
): Promise<string> {
const { getNodeDisplayPrice, pricingRevision } = useNodePricing()
const startRevision = pricingRevision.value
// Wait on pricingRevision (bumped when async evaluation settles) instead of a
// fixed sleep. A cache hit schedules nothing, so the bounded poll falls through.
getNodeDisplayPrice(node, widgetOverrides)
for (let attempt = 0; attempt < 50; attempt++) {
if (pricingRevision.value !== startRevision) break
await flushMacrotask()
}
return getNodeDisplayPrice(node, widgetOverrides)
}
function createStoredNodeDef(
name: string,
price_badge?: PriceBadge
): ComfyNodeDef {
return {
name,
display_name: name,
description: '',
category: 'test',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
output_node: false,
python_module: 'test',
price_badge
} satisfies ComfyNodeDef
}
// -----------------------------------------------------------------------------
// Tests
// -----------------------------------------------------------------------------
@@ -189,6 +233,32 @@ describe('useNodePricing', () => {
expect(price).toBe(creditsLabel(0.5))
})
it('should parse numeric strings and reject blank or invalid numbers', async () => {
const expression =
'{"type":"usd","usd": (widgets.count != null) ? widgets.count * 0.01 : 0.20}'
const badge = priceBadge(expression, [{ name: 'count', type: 'INT' }])
const parsedNode = createMockNodeWithPriceBadge(
'TestNumericStringNode',
badge,
[{ name: 'count', value: ' 5 ' }]
)
const blankNode = createMockNodeWithPriceBadge(
'TestBlankNumericStringNode',
badge,
[{ name: 'count', value: ' ' }]
)
const invalidNode = createMockNodeWithPriceBadge(
'TestInvalidNumericStringNode',
badge,
[{ name: 'count', value: 'five' }]
)
expect(await resolveDisplayPrice(parsedNode)).toBe(creditsLabel(0.05))
expect(await resolveDisplayPrice(blankNode)).toBe(creditsLabel(0.2))
expect(await resolveDisplayPrice(invalidNode)).toBe(creditsLabel(0.2))
})
it('should handle COMBO widget with numeric value', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
@@ -222,6 +292,19 @@ describe('useNodePricing', () => {
expect(price).toBe(creditsLabel(0.1))
})
it('should preserve boolean combo values', async () => {
const node = createMockNodeWithPriceBadge(
'TestComboBooleanNode',
priceBadge(
'(widgets.enabled = false) ? {"type":"usd","usd":0.04} : {"type":"usd","usd":0.08}',
[{ name: 'enabled', type: 'COMBO' }]
),
[{ name: 'enabled', value: false }]
)
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.04))
})
it('should handle BOOLEAN widget', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
@@ -238,6 +321,51 @@ describe('useNodePricing', () => {
expect(price).toBe(creditsLabel(0.1))
})
it('should parse BOOLEAN widget string values', async () => {
const badge = priceBadge(
'{"type":"usd","usd": widgets.premium ? 0.10 : 0.05}',
[{ name: 'premium', type: 'BOOLEAN' }]
)
const enabledNode = createMockNodeWithPriceBadge(
'TestBooleanStringTrueNode',
badge,
[{ name: 'premium', value: ' TRUE ' }]
)
const disabledNode = createMockNodeWithPriceBadge(
'TestBooleanStringFalseNode',
badge,
[{ name: 'premium', value: 'false' }]
)
expect(await resolveDisplayPrice(enabledNode)).toBe(creditsLabel(0.1))
expect(await resolveDisplayPrice(disabledNode)).toBe(creditsLabel(0.05))
})
it('should reject invalid BOOLEAN strings', async () => {
const node = createMockNodeWithPriceBadge(
'TestInvalidBooleanStringNode',
priceBadge(
'{"type":"usd","usd": widgets.premium = null ? 0.05 : 0.10}',
[{ name: 'premium', type: 'BOOLEAN' }]
),
[{ name: 'premium', value: 'sometimes' }]
)
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
})
it('should reject object values for numeric widgets', async () => {
const node = createMockNodeWithPriceBadge(
'TestObjectNumericNode',
priceBadge('{"type":"usd","usd": widgets.count = null ? 0.05 : 0.10}', [
{ name: 'count', type: 'INT' }
]),
[{ name: 'count', value: { count: 5 } }]
)
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
})
it('should handle STRING widget (lowercased)', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
@@ -468,6 +596,42 @@ describe('useNodePricing', () => {
})
})
describe('dependency context', () => {
it('should prefer widget overrides over node widget values', async () => {
const node = createMockNodeWithPriceBadge(
'TestWidgetOverrideNode',
priceBadge('{"type":"usd","usd": widgets.count * 0.01}', [
{ name: 'count', type: 'INT' }
]),
[{ name: 'count', value: 2 }]
)
const price = await resolveDisplayPrice(node, new Map([['count', '7']]))
expect(price).toBe(creditsLabel(0.07))
})
it('should treat missing input group arrays as zero connected inputs', async () => {
const node = Object.assign(createMockLGraphNode(), {
widgets: [],
constructor: {
nodeData: {
name: 'TestMissingInputGroupArrayNode',
api_node: true,
price_badge: priceBadge(
'{"type":"usd","usd": (inputGroups.images = 0) ? 0.05 : 0.10}',
[],
[],
['images']
)
}
}
})
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
})
})
describe('edge cases', () => {
it('should return empty string for non-API nodes', () => {
const { getNodeDisplayPrice } = useNodePricing()
@@ -595,6 +759,86 @@ describe('useNodePricing', () => {
})
})
describe('node type pricing dependencies', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('returns empty dependency metadata for node types without pricing', () => {
const store = useNodeDefStore()
store.addNodeDef(createStoredNodeDef('UnpricedNode'))
const {
getInputGroupPrefixes,
getInputNames,
getRelevantWidgetNames,
hasDynamicPricing
} = useNodePricing()
expect(getRelevantWidgetNames('UnpricedNode')).toEqual([])
expect(hasDynamicPricing('UnpricedNode')).toBe(false)
expect(getInputGroupPrefixes('UnpricedNode')).toEqual([])
expect(getInputNames('UnpricedNode')).toEqual([])
})
it('dedupes dynamic pricing dependencies while preserving order', () => {
const store = useNodeDefStore()
store.addNodeDef(
createStoredNodeDef(
'DynamicPricingNode',
priceBadge(
'{"type":"usd","usd":0.05}',
[
{ name: 'seed', type: 'INT' },
{ name: 'quality', type: 'COMBO' }
],
['image', 'seed'],
['clips', 'image']
)
)
)
const {
getInputGroupPrefixes,
getInputNames,
getRelevantWidgetNames,
hasDynamicPricing
} = useNodePricing()
expect(getRelevantWidgetNames('DynamicPricingNode')).toEqual([
'seed',
'quality',
'image',
'clips'
])
expect(hasDynamicPricing('DynamicPricingNode')).toBe(true)
expect(getInputGroupPrefixes('DynamicPricingNode')).toEqual([
'clips',
'image'
])
expect(getInputNames('DynamicPricingNode')).toEqual(['image', 'seed'])
})
it('handles fixed pricing metadata without dependencies', () => {
const store = useNodeDefStore()
store.addNodeDef(
createStoredNodeDef(
'FixedPricingNode',
priceBadge('{"type":"usd","usd":0.05}')
)
)
const {
getInputGroupPrefixes,
getInputNames,
getRelevantWidgetNames,
hasDynamicPricing
} = useNodePricing()
expect(getRelevantWidgetNames('FixedPricingNode')).toEqual([])
expect(hasDynamicPricing('FixedPricingNode')).toBe(false)
expect(getInputGroupPrefixes('FixedPricingNode')).toEqual([])
expect(getInputNames('FixedPricingNode')).toEqual([])
})
})
describe('reactive revision', () => {
it('bumps pricingRevision after an async evaluation resolves (Nodes 1.0 mode)', async () => {
const { getNodeDisplayPrice, pricingRevision } = useNodePricing()
@@ -743,6 +987,24 @@ describe('useNodePricing', () => {
expect(price).toBe('')
})
it('should reuse the cached empty label after runtime failures', async () => {
const { getNodeDisplayPrice, pricingRevision } = useNodePricing()
const node = createMockNodeWithPriceBadge(
'TestCachedRuntimeErrorNode',
priceBadge('$lookup(undefined, "key")')
)
expect(await resolveDisplayPrice(node)).toBe('')
const revisionAfterFailure = pricingRevision.value
// A second read of the same signature must reuse the cached empty label
// without scheduling another evaluation (no revision bump).
expect(getNodeDisplayPrice(node)).toBe('')
await new Promise((resolve) => setTimeout(resolve, 0))
expect(getNodeDisplayPrice(node)).toBe('')
expect(pricingRevision.value).toBe(revisionAfterFailure)
})
it('should return empty string for invalid PricingResult type', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
@@ -968,8 +1230,21 @@ describe('formatPricingResult', () => {
expect(result).toBe('~10.6')
})
it('should parse string usd values with default approximate formatting', () => {
const result = formatPricingResult(
{ type: 'usd', usd: '0.05' },
{ valueOnly: true, defaults: { approximate: true } }
)
expect(result).toBe('~10.6')
})
it('should return empty for null usd', () => {
const result = formatPricingResult({ type: 'usd', usd: null as never })
const result = formatPricingResult({ type: 'usd', usd: null })
expect(result).toBe('')
})
it('should return empty for blank string usd', () => {
const result = formatPricingResult({ type: 'usd', usd: ' ' })
expect(result).toBe('')
})
})
@@ -999,6 +1274,14 @@ describe('formatPricingResult', () => {
)
expect(result).toBe('10.6')
})
it('should parse string range values with default approximate formatting', () => {
const result = formatPricingResult(
{ type: 'range_usd', min_usd: '0.05', max_usd: '0.1' },
{ valueOnly: true, defaults: { approximate: true } }
)
expect(result).toBe('~10.6-21.1')
})
})
describe('type: list_usd', () => {
@@ -1017,6 +1300,22 @@ describe('formatPricingResult', () => {
)
expect(result).toBe('10.6/21.1')
})
it('should return valueOnly format with approximate prefix', () => {
const result = formatPricingResult(
{ type: 'list_usd', usd: [0.05, 0.1] },
{ valueOnly: true, defaults: { approximate: true } }
)
expect(result).toBe('~10.6/21.1')
})
it('should return empty when list value is not an array', () => {
const result = formatPricingResult({
type: 'list_usd',
usd: 'not-a-list'
})
expect(result).toBe('')
})
})
describe('type: text', () => {
@@ -1024,6 +1323,11 @@ describe('formatPricingResult', () => {
const result = formatPricingResult({ type: 'text', text: 'Free' })
expect(result).toBe('Free')
})
it('should return empty when text is missing', () => {
const result = formatPricingResult({ type: 'text' })
expect(result).toBe('')
})
})
describe('legacy format', () => {
@@ -1190,6 +1494,29 @@ describe('evaluateNodeDefPricing', () => {
expect(result).toBe('21.1') // 10 * 0.01 = 0.1 USD = 21.1 credits
})
it('should use default value from optional input spec', async () => {
const nodeDef = createMockNodeDef({
name: 'OptionalDefaultValueNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": widgets.count * 0.01}',
depends_on: {
widgets: [{ name: 'count', type: 'INT' }],
inputs: [],
input_groups: []
}
},
input: {
required: {},
optional: {
count: ['INT', { default: 4 }]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('8.4')
})
it('should use first option for COMBO without default', async () => {
const nodeDef = createMockNodeDef({
name: 'ComboNode',
@@ -1265,6 +1592,30 @@ describe('evaluateNodeDefPricing', () => {
expect(result).toBe('10.6')
})
it('should handle combo option arrays with primitive values', async () => {
const nodeDef = createMockNodeDef({
name: 'PrimitiveOptionsNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": widgets.mode = "fast" ? 0.05 : 0.10}',
depends_on: {
widgets: [{ name: 'mode', type: 'COMBO' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
mode: ['COMBO', { options: ['fast', 'slow'] }]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('10.6')
})
it('should assume inputs disconnected in preview', async () => {
const nodeDef = createMockNodeDef({
name: 'InputConnectedNode',

View File

@@ -1,147 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { FuseSearchable } from '@/utils/fuseUtil'
import { FuseFilter, FuseSearch } from '@/utils/fuseUtil'
interface SearchItem extends Partial<FuseSearchable> {
name: string
}
interface FilterItem {
options: string[]
}
const makeSearch = <T>(data: T[] = []) =>
new FuseSearch<T>(data, {
fuseOptions: {
keys: ['name'],
includeScore: true,
threshold: 0.6,
shouldSort: false
},
advancedScoring: true
})
describe('FuseSearch', () => {
it('assigns stable ranking tiers for exact, prefix, word, substring, and multi-part matches', () => {
const search = new FuseSearch<string>([], {})
const cases = [
{ query: 'load image', item: 'load image', tier: 0 },
{ query: 'load', item: 'Load Image', tier: 1 },
{ query: 'image', item: 'LoadImage', tier: 2 },
{ query: 'cast', item: 'broadcast', tier: 3 },
{ query: 'batch latent', item: 'LatentBatch', tier: 4 },
{ query: 'ten bat', item: 'LatentBatch', tier: 5 },
{ query: 'vae', item: 'KSampler', tier: 9 }
]
for (const { query, item, tier } of cases) {
expect(search.calcAuxSingle(query, item, 0)[0]).toBe(tier)
}
})
it('penalizes deprecated non-exact matches without penalizing exact matches', () => {
const search = makeSearch<SearchItem>()
expect(
search.calcAuxScores('image', { name: 'Image Deprecated' }, 0)[0]
).toBe(6)
expect(
search.calcAuxScores('deprecated node', { name: 'Deprecated Node' }, 0)[0]
).toBe(0)
})
it('lets searchable entries post-process their auxiliary scores', () => {
const search = makeSearch<SearchItem>()
const entry: SearchItem = {
name: 'Image Loader',
postProcessSearchScores: (scores) => [scores[0] + 2, ...scores.slice(1)]
}
expect(search.calcAuxScores('image', entry, 0)[0]).toBe(3)
})
it('sorts advanced search results by auxiliary ranking instead of Fuse order', () => {
const exact = { name: 'Image' }
const prefix = { name: 'Image Loader' }
const camelCaseWord = { name: 'LoadImage' }
const substring = { name: 'PreimageNode' }
const deprecated = { name: 'Image Deprecated' }
const search = makeSearch([
substring,
deprecated,
camelCaseWord,
prefix,
exact
])
expect(search.search('image')).toEqual([
exact,
prefix,
camelCaseWord,
substring,
deprecated
])
})
it('returns data in original order for an empty query without calling Fuse', () => {
const data = [{ name: 'B' }, { name: 'A' }]
const search = makeSearch(data)
const fuseSearchSpy = vi.spyOn(search.fuse, 'search')
expect(search.search('')).toEqual(data)
expect(fuseSearchSpy).not.toHaveBeenCalled()
})
it('compares auxiliary scores by the first differing value and then length', () => {
const search = new FuseSearch<string>([], {})
expect(
[
[1, 4],
[1, 2],
[0, 99]
].sort(search.compareAux)
).toEqual([
[0, 99],
[1, 2],
[1, 4]
])
expect(
[
[1, 2, 0],
[1, 2]
].sort(search.compareAux)
).toEqual([
[1, 2],
[1, 2, 0]
])
})
})
describe('FuseFilter', () => {
it('matches single values, comma-separated values, and wildcard fallbacks', () => {
const imageItem = { options: ['IMAGE', 'LATENT'] }
const modelItem = { options: ['MODEL'] }
const filter = new FuseFilter<FilterItem, string>([imageItem, modelItem], {
id: 'type',
name: 'Type',
invokeSequence: 't',
getItemOptions: (item) => item.options
})
expect(filter.getAllNodeOptions([imageItem, modelItem, imageItem])).toEqual(
['IMAGE', 'LATENT', 'MODEL']
)
expect(filter.matches(imageItem, 'IMAGE')).toBe(true)
expect(filter.matches(imageItem, 'MODEL')).toBe(false)
expect(filter.matches(imageItem, 'MODEL,IMAGE')).toBe(true)
expect(filter.matches(modelItem, '*', { wildcard: '*' })).toBe(true)
expect(filter.matches(imageItem, 'MODEL', { wildcard: 'IMAGE' })).toBe(true)
expect(filter.matches(modelItem, 'MODEL', { wildcard: 'IMAGE' })).toBe(
false
)
})
})

View File

@@ -1,55 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createGridStyle } from '@/utils/gridUtil'
describe('createGridStyle', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('uses auto-fill columns by default', () => {
expect(createGridStyle()).toEqual({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(15rem, 1fr))',
padding: '0',
gap: '1rem'
})
})
it('uses fixed columns when provided', () => {
expect(
createGridStyle({
columns: 3,
padding: '8px',
gap: '4px'
})
).toEqual({
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
padding: '8px',
gap: '4px'
})
})
it('warns and clamps invalid fixed columns', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
expect(createGridStyle({ columns: -1 }).gridTemplateColumns).toBe(
'repeat(1, 1fr)'
)
expect(warn).toHaveBeenCalledWith(
'createGridStyle: columns must be >= 1, defaulting to 1'
)
})
it('warns for columns: 0 but falls through to auto-fill (falsy zero)', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
expect(createGridStyle({ columns: 0 }).gridTemplateColumns).toBe(
'repeat(auto-fill, minmax(15rem, 1fr))'
)
expect(warn).toHaveBeenCalledWith(
'createGridStyle: columns must be >= 1, defaulting to 1'
)
})
})

View File

@@ -1,39 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { whileMouseDown } from '@/utils/mouseDownUtil'
describe('whileMouseDown', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('runs until the element receives mouseup', () => {
const element = document.createElement('button')
const callback = vi.fn()
whileMouseDown(element, callback, 10)
vi.advanceTimersByTime(25)
element.dispatchEvent(new MouseEvent('mouseup'))
vi.advanceTimersByTime(30)
expect(callback.mock.calls).toEqual([[0], [1]])
})
it('uses the event target and stops on document mouseup', () => {
const element = document.createElement('button')
const event = new MouseEvent('mousedown')
Object.defineProperty(event, 'target', { value: element })
const callback = vi.fn()
whileMouseDown(event, callback, 5)
vi.advanceTimersByTime(12)
document.dispatchEvent(new MouseEvent('mouseup'))
vi.advanceTimersByTime(20)
expect(callback.mock.calls).toEqual([[0], [1]])
})
})

View File

@@ -1,52 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
const options = {
emptyLabel: 'Empty Node',
untitledLabel: 'Untitled Node',
st: vi.fn((key: string, fallback: string) => `${key}:${fallback}`)
}
describe('resolveNodeDisplayName', () => {
beforeEach(() => {
options.st.mockClear()
})
it('uses the empty label when no node is available', () => {
expect(resolveNodeDisplayName(null, options)).toBe('Empty Node')
expect(resolveNodeDisplayName(undefined, options)).toBe('Empty Node')
expect(options.st).not.toHaveBeenCalled()
})
it('prefers a trimmed explicit title', () => {
expect(
resolveNodeDisplayName(
{ title: ' KSampler ', type: 'Ignored' },
options
)
).toBe('KSampler')
expect(options.st).not.toHaveBeenCalled()
})
it('translates the node type when the title is empty', () => {
const result = resolveNodeDisplayName(
{ title: '', type: 'CLIP Text Encode' },
options
)
const expectedKey = `nodeDefs.${normalizeI18nKey('CLIP Text Encode')}.display_name`
expect(options.st).toHaveBeenCalledWith(expectedKey, 'CLIP Text Encode')
expect(result).toBe(`${expectedKey}:CLIP Text Encode`)
})
it('falls back to the untitled label when title and type are empty', () => {
const expectedKey = `nodeDefs.${normalizeI18nKey('Untitled Node')}.display_name`
expect(resolveNodeDisplayName({ title: '', type: '' }, options)).toBe(
`${expectedKey}:Untitled Node`
)
expect(resolveNodeDisplayName({}, options)).toBe(
`${expectedKey}:Untitled Node`
)
})
})

View File

@@ -1,48 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
createSharedObjectUrl,
releaseSharedObjectUrl,
retainSharedObjectUrl
} from './objectUrlUtil'
describe('objectUrlUtil', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('retains and releases shared blob URLs by reference count', () => {
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL')
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test')
const url = createSharedObjectUrl(new Blob(['data']))
retainSharedObjectUrl(url)
releaseSharedObjectUrl(url)
expect(revokeObjectURL).not.toHaveBeenCalled()
releaseSharedObjectUrl(url)
expect(revokeObjectURL).toHaveBeenCalledWith(url)
})
it('ignores missing and non-blob URLs', () => {
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL')
retainSharedObjectUrl(undefined)
retainSharedObjectUrl('https://example.com/image.png')
releaseSharedObjectUrl(undefined)
releaseSharedObjectUrl('https://example.com/image.png')
expect(revokeObjectURL).not.toHaveBeenCalled()
})
it('revokes unknown blob URLs once', () => {
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL')
releaseSharedObjectUrl('blob:unknown')
expect(revokeObjectURL).toHaveBeenCalledOnce()
expect(revokeObjectURL).toHaveBeenCalledWith('blob:unknown')
})
})

View File

@@ -1,307 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { JobState } from '@/types/queue'
import type { BuildJobDisplayCtx } from '@/utils/queueDisplay'
import { buildJobDisplay, iconForJobState } from '@/utils/queueDisplay'
type QueueDisplayTask = Parameters<typeof buildJobDisplay>[0]
type PreviewOutput = NonNullable<QueueDisplayTask['previewOutput']>
function createJob(
status: JobListItem['status'],
overrides: Partial<JobListItem> = {}
): JobListItem {
return {
id: 'job-123456',
status,
create_time: 1_710_000_000_000,
priority: 12,
...overrides
}
}
function createTask(
options: {
job?: Partial<JobListItem>
jobId?: string
createTime?: number | undefined
executionTime?: number
executionTimeInSeconds?: number
previewOutput?: PreviewOutput
} = {}
): QueueDisplayTask {
const {
job,
jobId = 'job-123456',
executionTime,
executionTimeInSeconds,
previewOutput
} = options
const createTime = Object.hasOwn(options, 'createTime')
? options.createTime
: 1_710_000_000_000
return {
job: createJob(job?.status ?? 'pending', job),
jobId,
createTime,
executionTime,
executionTimeInSeconds,
previewOutput
} as QueueDisplayTask
}
function createCtx(
overrides: Partial<BuildJobDisplayCtx> = {}
): BuildJobDisplayCtx {
return {
t: (key, values) => {
const entries = Object.entries(values ?? {})
if (!entries.length) return key
return `${key}(${entries
.map(([name, value]) => `${name}=${String(value)}`)
.join(',')})`
},
locale: 'en-US',
formatClockTimeFn: (ts, locale) => `${locale}:${ts}`,
isActive: false,
...overrides
}
}
describe('iconForJobState', () => {
it.for<[JobState, string]>([
['pending', 'icon-[lucide--loader-circle]'],
['initialization', 'icon-[lucide--server-crash]'],
['running', 'icon-[lucide--zap]'],
['completed', 'icon-[lucide--check-check]'],
['failed', 'icon-[lucide--alert-circle]']
])('maps %s to its icon', ([state, icon]) => {
expect(iconForJobState(state)).toBe(icon)
})
it('uses a neutral icon for unrecognized states', () => {
expect(iconForJobState('archived' as JobState)).toBe(
'icon-[lucide--circle]'
)
})
})
describe('buildJobDisplay', () => {
it('shows the added hint for pending jobs when requested', () => {
expect(
buildJobDisplay(
createTask(),
'pending',
createCtx({ showAddedHint: true })
)
).toEqual({
iconName: 'icon-[lucide--check]',
primary: 'queue.jobAddedToQueue',
secondary: 'en-US:1710000000000',
showClear: true
})
})
it('shows queued time for pending and initializing jobs', () => {
expect(buildJobDisplay(createTask(), 'pending', createCtx())).toMatchObject(
{
iconName: 'icon-[lucide--loader-circle]',
primary: 'queue.inQueue',
secondary: 'en-US:1710000000000',
showClear: true
}
)
expect(
buildJobDisplay(createTask(), 'initialization', createCtx())
).toMatchObject({
iconName: 'icon-[lucide--server-crash]',
primary: 'queue.initializingAlmostReady',
secondary: 'en-US:1710000000000',
showClear: true
})
})
it('formats active running progress from the injected context', () => {
expect(
buildJobDisplay(
createTask({ job: { status: 'in_progress' } }),
'running',
createCtx({
isActive: true,
totalPercent: 42.7,
currentNodePercent: -10,
currentNodeName: 'KSampler'
})
)
).toEqual({
iconName: 'icon-[lucide--zap]',
primary: 'sideToolbar.queueProgressOverlay.total(percent=43%)',
secondary:
'KSampler sideToolbar.queueProgressOverlay.colonPercent(percent=0%)',
showClear: true
})
})
it('omits current node progress when the active job has no node name', () => {
expect(
buildJobDisplay(
createTask({ job: { status: 'in_progress' } }),
'running',
createCtx({
isActive: true,
totalPercent: 101,
currentNodePercent: 50
})
)
).toMatchObject({
primary: 'sideToolbar.queueProgressOverlay.total(percent=100%)',
secondary: ''
})
})
it('uses a compact running label when the job is not active', () => {
expect(
buildJobDisplay(
createTask({ job: { status: 'in_progress' } }),
'running',
createCtx()
)
).toEqual({
iconName: 'icon-[lucide--zap]',
primary: 'g.running',
secondary: '',
showClear: true
})
})
it('shows local completed jobs as the preview filename', () => {
expect(
buildJobDisplay(
createTask({
job: {
status: 'completed'
},
executionTimeInSeconds: 3.51,
previewOutput: {
filename: 'preview.png',
isImage: true,
url: '/api/view?filename=preview.png&type=output&subfolder='
} as PreviewOutput
}),
'completed',
createCtx()
)
).toEqual({
iconName: 'icon-[lucide--check-check]',
iconImageUrl: '/api/view?filename=preview.png&type=output&subfolder=',
primary: 'preview.png',
secondary: '3.51s',
showClear: false
})
})
it('shows cloud completed jobs as elapsed time', () => {
expect(
buildJobDisplay(
createTask({
job: {
status: 'completed'
},
executionTime: 64_000,
executionTimeInSeconds: 64
}),
'completed',
createCtx({ isCloud: true })
)
).toMatchObject({
iconName: 'icon-[lucide--check-check]',
primary: 'queue.completedIn(duration=1m 4s)',
secondary: '64.00s',
showClear: false
})
})
it('falls back to job title for completed jobs without a preview filename', () => {
expect(
buildJobDisplay(
createTask({
job: {
status: 'completed',
priority: 42
}
}),
'completed',
createCtx()
)
).toMatchObject({
iconName: 'icon-[lucide--check-check]',
primary: 'g.job #42',
secondary: '',
showClear: false
})
})
it('builds completed fallback titles from the job id', () => {
expect(
buildJobDisplay(
createTask({
jobId: 'abcdef-123',
job: { status: 'completed', priority: undefined }
}),
'completed',
createCtx()
).primary
).toBe('g.job abcdef')
})
it('uses the generic completed fallback title when ids are empty', () => {
expect(
buildJobDisplay(
createTask({
jobId: '',
job: { status: 'completed', id: '', priority: undefined }
}),
'completed',
createCtx()
).primary
).toBe('g.job')
})
it('uses an empty queued timestamp when create time is unavailable', () => {
expect(
buildJobDisplay(
createTask({ createTime: undefined }),
'pending',
createCtx()
).secondary
).toBe('')
})
it('shows failed jobs as clearable failures', () => {
expect(buildJobDisplay(createTask(), 'failed', createCtx())).toEqual({
iconName: 'icon-[lucide--alert-circle]',
primary: 'g.failed',
secondary: 'g.failed',
showClear: true
})
})
it('falls back to a neutral clearable display for unrecognized states', () => {
expect(
buildJobDisplay(
createTask({ jobId: 'abcdef-123' }),
'archived' as JobState,
createCtx()
)
).toEqual({
iconName: 'icon-[lucide--circle]',
primary: 'g.job #12',
secondary: '',
showClear: true
})
})
})

View File

@@ -1,67 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createRafBatch } from '@/utils/rafBatch'
describe('createRafBatch', () => {
const callbacks = new Map<number, FrameRequestCallback>()
const cancelAnimationFrame = vi.fn()
beforeEach(() => {
callbacks.clear()
cancelAnimationFrame.mockClear()
let nextId = 0
vi.stubGlobal(
'requestAnimationFrame',
vi.fn((callback: FrameRequestCallback) => {
const id = ++nextId
callbacks.set(id, callback)
return id
})
)
vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('coalesces scheduled work into one animation frame', () => {
const run = vi.fn()
const batch = createRafBatch(run)
batch.schedule()
batch.schedule()
expect(requestAnimationFrame).toHaveBeenCalledOnce()
expect(batch.isScheduled()).toBe(true)
callbacks.get(1)?.(0)
expect(run).toHaveBeenCalledOnce()
expect(batch.isScheduled()).toBe(false)
})
it('cancels and flushes scheduled work', () => {
const run = vi.fn()
const batch = createRafBatch(run)
batch.cancel()
batch.flush()
expect(cancelAnimationFrame).not.toHaveBeenCalled()
expect(run).not.toHaveBeenCalled()
batch.schedule()
batch.cancel()
expect(cancelAnimationFrame).toHaveBeenCalledWith(1)
expect(batch.isScheduled()).toBe(false)
batch.schedule()
batch.flush()
expect(cancelAnimationFrame).toHaveBeenCalledWith(2)
expect(run).toHaveBeenCalledOnce()
expect(batch.isScheduled()).toBe(false)
})
})

View File

@@ -1,14 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
isAbortError,
isNonNullish,
isResultItemType,
isSlotObject,
isSubgraph,
isSubgraphIoNode
} from '@/utils/typeGuardUtil'
import { isSubgraphIoNode } from '@/utils/typeGuardUtil'
type NodeConstructor = { comfyClass?: string }
@@ -17,40 +10,6 @@ function createMockNode(nodeConstructor?: NodeConstructor): LGraphNode {
}
describe('typeGuardUtil', () => {
describe('isAbortError', () => {
it('matches AbortError DOMExceptions only', () => {
expect(isAbortError(new DOMException('cancelled', 'AbortError'))).toBe(
true
)
expect(isAbortError(new DOMException('failed', 'NetworkError'))).toBe(
false
)
expect(isAbortError({ name: 'AbortError' })).toBe(false)
})
})
describe('isSubgraph', () => {
it('matches non-root graphs only', () => {
const subgraph = {
isRootGraph: false
} as Parameters<typeof isSubgraph>[0]
const rootGraph = {
isRootGraph: true
} as Parameters<typeof isSubgraph>[0]
expect(isSubgraph(subgraph)).toBe(true)
expect(isSubgraph(rootGraph)).toBe(false)
expect(isSubgraph(null)).toBe(false)
})
})
describe('isNonNullish', () => {
it('filters nullish values without dropping falsy data', () => {
const values = [0, '', null, undefined, false, 'ok']
expect(values.filter(isNonNullish)).toEqual([0, '', false, 'ok'])
})
})
describe('isSubgraphIoNode', () => {
it('should identify SubgraphInputNode as IO node', () => {
const node = createMockNode({ comfyClass: 'SubgraphInputNode' })
@@ -82,23 +41,4 @@ describe('typeGuardUtil', () => {
expect(isSubgraphIoNode(node)).toBe(false)
})
})
describe('isSlotObject', () => {
it('requires the slot shape fields', () => {
expect(
isSlotObject({ name: 'image', type: 'IMAGE', boundingRect: [] })
).toBe(true)
expect(isSlotObject(null)).toBe(false)
expect(isSlotObject('image')).toBe(false)
expect(isSlotObject({ name: 'image', type: 'IMAGE' })).toBe(false)
})
})
describe('isResultItemType', () => {
it('recognizes backend result buckets', () => {
expect(['input', 'output', 'temp'].every(isResultItemType)).toBe(true)
expect(isResultItemType('cache')).toBe(false)
expect(isResultItemType(undefined)).toBe(false)
})
})
})