mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 05:38:26 +00:00
Compare commits
1 Commits
shihchi/co
...
shihchi/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af733e9732 |
79
src/base/common/async.test.ts
Normal file
79
src/base/common/async.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CREDITS_PER_USD,
|
||||
COMFY_CREDIT_RATE_CENTS,
|
||||
centsToCredits,
|
||||
clampUsd,
|
||||
creditsToCents,
|
||||
creditsToUsd,
|
||||
formatCredits,
|
||||
@@ -43,4 +44,23 @@ 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,294 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,175 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -10,43 +10,30 @@ import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
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 }
|
||||
}
|
||||
}))
|
||||
|
||||
// canvasStore transitively imports the app singleton; stub it so the real
|
||||
// ComfyApp module never loads during these unit tests.
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { selected_nodes: null } }
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeCustomization', () => ({
|
||||
useNodeCustomization: () => ({
|
||||
shapeOptions: customization.shapeOptions,
|
||||
applyShape: customization.applyShape,
|
||||
applyColor: customization.applyColor,
|
||||
colorOptions: customization.colorOptions,
|
||||
isLightTheme: customization.isLightTheme
|
||||
shapeOptions: [],
|
||||
applyShape: vi.fn(),
|
||||
applyColor: vi.fn(),
|
||||
colorOptions: [],
|
||||
isLightTheme: { value: false }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectedNodeActions', () => ({
|
||||
useSelectedNodeActions: () => actions
|
||||
useSelectedNodeActions: () => ({
|
||||
adjustNodeSize: vi.fn(),
|
||||
toggleNodeCollapse: vi.fn(),
|
||||
toggleNodePin: vi.fn(),
|
||||
toggleNodeBypass: vi.fn(),
|
||||
runBranch: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
@@ -82,29 +69,9 @@ const getBypassLabel = (selected: LGraphNode[]): string => {
|
||||
return label
|
||||
}
|
||||
|
||||
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', () => {
|
||||
describe('useNodeMenuOptions.getBypassOption', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
customization.shapeOptions = []
|
||||
customization.colorOptions = []
|
||||
customization.isLightTheme.value = false
|
||||
})
|
||||
|
||||
it('labels as "Bypass" when no node is bypassed', () => {
|
||||
@@ -130,109 +97,4 @@ describe('useNodeMenuOptions', () => {
|
||||
])
|
||||
).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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -8,12 +8,7 @@ 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,
|
||||
isLGraphGroup,
|
||||
isLGraphNode,
|
||||
isLoad3dNode
|
||||
} from '@/utils/litegraphUtil'
|
||||
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
import {
|
||||
createMockLGraphNode,
|
||||
@@ -22,9 +17,7 @@ import {
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLGraphNode: vi.fn(),
|
||||
isImageNode: vi.fn(),
|
||||
isLGraphGroup: vi.fn(),
|
||||
isLoad3dNode: vi.fn()
|
||||
isImageNode: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/nodeFilterUtil', () => ({
|
||||
@@ -103,16 +96,6 @@ 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')
|
||||
)
|
||||
@@ -152,21 +135,6 @@ 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', () => {
|
||||
@@ -247,13 +215,6 @@ 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', () => {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits'
|
||||
import {
|
||||
@@ -14,7 +12,6 @@ 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'
|
||||
|
||||
@@ -126,47 +123,6 @@ 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
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -233,32 +189,6 @@ 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(
|
||||
@@ -292,19 +222,6 @@ 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(
|
||||
@@ -321,51 +238,6 @@ 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(
|
||||
@@ -596,42 +468,6 @@ 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()
|
||||
@@ -759,86 +595,6 @@ 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()
|
||||
@@ -987,24 +743,6 @@ 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(
|
||||
@@ -1230,21 +968,8 @@ 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 })
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty for blank string usd', () => {
|
||||
const result = formatPricingResult({ type: 'usd', usd: ' ' })
|
||||
const result = formatPricingResult({ type: 'usd', usd: null as never })
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -1274,14 +999,6 @@ 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', () => {
|
||||
@@ -1300,22 +1017,6 @@ 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', () => {
|
||||
@@ -1323,11 +1024,6 @@ 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', () => {
|
||||
@@ -1494,29 +1190,6 @@ 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',
|
||||
@@ -1592,30 +1265,6 @@ 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',
|
||||
|
||||
147
src/utils/fuseUtil.test.ts
Normal file
147
src/utils/fuseUtil.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
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
|
||||
)
|
||||
})
|
||||
})
|
||||
55
src/utils/gridUtil.test.ts
Normal file
55
src/utils/gridUtil.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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'
|
||||
)
|
||||
})
|
||||
})
|
||||
39
src/utils/mouseDownUtil.test.ts
Normal file
39
src/utils/mouseDownUtil.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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]])
|
||||
})
|
||||
})
|
||||
52
src/utils/nodeTitleUtil.test.ts
Normal file
52
src/utils/nodeTitleUtil.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
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`
|
||||
)
|
||||
})
|
||||
})
|
||||
48
src/utils/objectUrlUtil.test.ts
Normal file
48
src/utils/objectUrlUtil.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
307
src/utils/queueDisplay.test.ts
Normal file
307
src/utils/queueDisplay.test.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
67
src/utils/rafBatch.test.ts
Normal file
67
src/utils/rafBatch.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { isSubgraphIoNode } from '@/utils/typeGuardUtil'
|
||||
import {
|
||||
isAbortError,
|
||||
isNonNullish,
|
||||
isResultItemType,
|
||||
isSlotObject,
|
||||
isSubgraph,
|
||||
isSubgraphIoNode
|
||||
} from '@/utils/typeGuardUtil'
|
||||
|
||||
type NodeConstructor = { comfyClass?: string }
|
||||
|
||||
@@ -10,6 +17,40 @@ 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' })
|
||||
@@ -41,4 +82,23 @@ 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user