mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-07 23:57:53 +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)
|
||||
})
|
||||
})
|
||||
|
||||
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