Compare commits

...

1 Commits

Author SHA1 Message Date
huang47
af733e9732 test: cover utility and base branch gaps 2026-07-01 00:24:02 -07:00
10 changed files with 875 additions and 1 deletions

View 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()
})
})

View File

@@ -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)
})
})

147
src/utils/fuseUtil.test.ts Normal file
View 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
)
})
})

View 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'
)
})
})

View 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]])
})
})

View 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`
)
})
})

View 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')
})
})

View 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
})
})
})

View 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)
})
})

View File

@@ -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)
})
})
})