mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-28 02:02:08 +00:00
fix: merge main into webcam-capture branch
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
const mockUpdateModelsForNodeType = vi.fn()
|
||||
const mockGetCategoryForNodeType = vi.fn()
|
||||
|
||||
vi.mock('@/stores/assetsStore', () => ({
|
||||
useAssetsStore: () => ({
|
||||
getAssets: () => [],
|
||||
isModelLoading: () => false,
|
||||
getError: () => undefined,
|
||||
hasAssetKey: () => false,
|
||||
updateModelsForNodeType: mockUpdateModelsForNodeType
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: () => ({
|
||||
getCategoryForNodeType: mockGetCategoryForNodeType
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useAssetWidgetData (desktop/isCloud=false)', () => {
|
||||
it('returns empty/default values without calling stores', () => {
|
||||
const nodeType = ref('CheckpointLoaderSimple')
|
||||
const { category, assets, dropdownItems, isLoading, error } =
|
||||
useAssetWidgetData(nodeType)
|
||||
|
||||
expect(category.value).toBeUndefined()
|
||||
expect(assets.value).toEqual([])
|
||||
expect(dropdownItems.value).toEqual([])
|
||||
expect(isLoading.value).toBe(false)
|
||||
expect(error.value).toBeNull()
|
||||
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
|
||||
expect(mockGetCategoryForNodeType).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,254 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
const mockAssetsByKey = new Map<string, AssetItem[]>()
|
||||
const mockLoadingByKey = new Map<string, boolean>()
|
||||
const mockErrorByKey = new Map<string, Error | undefined>()
|
||||
const mockInitializedKeys = new Set<string>()
|
||||
const mockUpdateModelsForNodeType = vi.fn()
|
||||
const mockGetCategoryForNodeType = vi.fn()
|
||||
|
||||
vi.mock('@/stores/assetsStore', () => ({
|
||||
useAssetsStore: () => ({
|
||||
getAssets: (key: string) => mockAssetsByKey.get(key) ?? [],
|
||||
isModelLoading: (key: string) => mockLoadingByKey.get(key) ?? false,
|
||||
getError: (key: string) => mockErrorByKey.get(key),
|
||||
hasAssetKey: (key: string) => mockInitializedKeys.has(key),
|
||||
updateModelsForNodeType: mockUpdateModelsForNodeType
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: () => ({
|
||||
getCategoryForNodeType: mockGetCategoryForNodeType
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAssetsByKey.clear()
|
||||
mockLoadingByKey.clear()
|
||||
mockErrorByKey.clear()
|
||||
mockInitializedKeys.clear()
|
||||
mockGetCategoryForNodeType.mockReturnValue(undefined)
|
||||
|
||||
mockUpdateModelsForNodeType.mockImplementation(
|
||||
async (): Promise<AssetItem[]> => {
|
||||
return []
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const createMockAsset = (
|
||||
id: string,
|
||||
name: string,
|
||||
filename: string,
|
||||
previewUrl?: string
|
||||
): AssetItem => ({
|
||||
id,
|
||||
name,
|
||||
size: 1024,
|
||||
tags: ['models', 'checkpoints'],
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
preview_url: previewUrl,
|
||||
user_metadata: {
|
||||
filename
|
||||
}
|
||||
})
|
||||
|
||||
it('fetches assets and transforms to dropdown items', async () => {
|
||||
const mockAssets: AssetItem[] = [
|
||||
createMockAsset(
|
||||
'asset-1',
|
||||
'Beautiful Model',
|
||||
'models/beautiful_model.safetensors',
|
||||
'/api/preview/asset-1'
|
||||
),
|
||||
createMockAsset('asset-2', 'Model B', 'model_b.safetensors', '/preview/2')
|
||||
]
|
||||
|
||||
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
|
||||
|
||||
mockUpdateModelsForNodeType.mockImplementation(
|
||||
async (_nodeType: string): Promise<AssetItem[]> => {
|
||||
mockInitializedKeys.add(_nodeType)
|
||||
mockAssetsByKey.set(_nodeType, mockAssets)
|
||||
mockLoadingByKey.set(_nodeType, false)
|
||||
return mockAssets
|
||||
}
|
||||
)
|
||||
|
||||
const nodeType = ref('CheckpointLoaderSimple')
|
||||
const { category, assets, dropdownItems, isLoading } =
|
||||
useAssetWidgetData(nodeType)
|
||||
|
||||
await nextTick()
|
||||
await vi.waitFor(() => !isLoading.value)
|
||||
|
||||
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(category.value).toBe('checkpoints')
|
||||
expect(assets.value).toEqual(mockAssets)
|
||||
|
||||
expect(dropdownItems.value).toHaveLength(2)
|
||||
const item = dropdownItems.value[0]
|
||||
expect(item.id).toBe('asset-1')
|
||||
expect(item.name).toBe('models/beautiful_model.safetensors')
|
||||
expect(item.label).toBe('Beautiful Model')
|
||||
expect(item.mediaSrc).toBe('/api/preview/asset-1')
|
||||
})
|
||||
|
||||
it('handles API errors gracefully', async () => {
|
||||
const mockError = new Error('Network error')
|
||||
|
||||
mockUpdateModelsForNodeType.mockImplementation(
|
||||
async (_nodeType: string): Promise<AssetItem[]> => {
|
||||
mockInitializedKeys.add(_nodeType)
|
||||
mockErrorByKey.set(_nodeType, mockError)
|
||||
mockAssetsByKey.set(_nodeType, [])
|
||||
mockLoadingByKey.set(_nodeType, false)
|
||||
return []
|
||||
}
|
||||
)
|
||||
|
||||
const nodeType = ref('CheckpointLoaderSimple')
|
||||
const { assets, error, isLoading } = useAssetWidgetData(nodeType)
|
||||
|
||||
await nextTick()
|
||||
await vi.waitFor(() => !isLoading.value)
|
||||
|
||||
expect(error.value).toBe(mockError)
|
||||
expect(assets.value).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty for unknown node type', async () => {
|
||||
mockGetCategoryForNodeType.mockReturnValue(undefined)
|
||||
|
||||
mockUpdateModelsForNodeType.mockImplementation(
|
||||
async (_nodeType: string): Promise<AssetItem[]> => {
|
||||
mockInitializedKeys.add(_nodeType)
|
||||
mockAssetsByKey.set(_nodeType, [])
|
||||
mockLoadingByKey.set(_nodeType, false)
|
||||
return []
|
||||
}
|
||||
)
|
||||
|
||||
const nodeType = ref('UnknownNodeType')
|
||||
const { category, assets } = useAssetWidgetData(nodeType)
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(category.value).toBeUndefined()
|
||||
expect(assets.value).toEqual([])
|
||||
})
|
||||
|
||||
describe('MaybeRefOrGetter parameter support', () => {
|
||||
it('accepts plain string value', async () => {
|
||||
const mockAssets: AssetItem[] = [
|
||||
createMockAsset('asset-1', 'Model A', 'model_a.safetensors')
|
||||
]
|
||||
|
||||
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
|
||||
mockUpdateModelsForNodeType.mockImplementation(
|
||||
async (_nodeType: string): Promise<AssetItem[]> => {
|
||||
mockInitializedKeys.add(_nodeType)
|
||||
mockAssetsByKey.set(_nodeType, mockAssets)
|
||||
mockLoadingByKey.set(_nodeType, false)
|
||||
return mockAssets
|
||||
}
|
||||
)
|
||||
|
||||
const { category, assets, isLoading } = useAssetWidgetData(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
await vi.waitFor(() => !isLoading.value)
|
||||
|
||||
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(category.value).toBe('checkpoints')
|
||||
expect(assets.value).toEqual(mockAssets)
|
||||
})
|
||||
|
||||
it('accepts getter function', async () => {
|
||||
const mockAssets: AssetItem[] = [
|
||||
createMockAsset('asset-1', 'Model A', 'model_a.safetensors')
|
||||
]
|
||||
|
||||
mockGetCategoryForNodeType.mockReturnValue('loras')
|
||||
mockUpdateModelsForNodeType.mockImplementation(
|
||||
async (_nodeType: string): Promise<AssetItem[]> => {
|
||||
mockInitializedKeys.add(_nodeType)
|
||||
mockAssetsByKey.set(_nodeType, mockAssets)
|
||||
mockLoadingByKey.set(_nodeType, false)
|
||||
return mockAssets
|
||||
}
|
||||
)
|
||||
|
||||
const nodeType = ref('LoraLoader')
|
||||
const { category, assets, isLoading } = useAssetWidgetData(
|
||||
() => nodeType.value
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
await vi.waitFor(() => !isLoading.value)
|
||||
|
||||
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith('LoraLoader')
|
||||
expect(category.value).toBe('loras')
|
||||
expect(assets.value).toEqual(mockAssets)
|
||||
})
|
||||
|
||||
it('accepts ref (backward compatibility)', async () => {
|
||||
const mockAssets: AssetItem[] = [
|
||||
createMockAsset('asset-1', 'Model A', 'model_a.safetensors')
|
||||
]
|
||||
|
||||
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
|
||||
mockUpdateModelsForNodeType.mockImplementation(
|
||||
async (_nodeType: string): Promise<AssetItem[]> => {
|
||||
mockInitializedKeys.add(_nodeType)
|
||||
mockAssetsByKey.set(_nodeType, mockAssets)
|
||||
mockLoadingByKey.set(_nodeType, false)
|
||||
return mockAssets
|
||||
}
|
||||
)
|
||||
|
||||
const nodeTypeRef = ref('CheckpointLoaderSimple')
|
||||
const { category, assets, isLoading } = useAssetWidgetData(nodeTypeRef)
|
||||
|
||||
await nextTick()
|
||||
await vi.waitFor(() => !isLoading.value)
|
||||
|
||||
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(category.value).toBe('checkpoints')
|
||||
expect(assets.value).toEqual(mockAssets)
|
||||
})
|
||||
|
||||
it('handles undefined node type gracefully', async () => {
|
||||
const { category, assets, dropdownItems, isLoading, error } =
|
||||
useAssetWidgetData(undefined)
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
|
||||
expect(category.value).toBeUndefined()
|
||||
expect(assets.value).toEqual([])
|
||||
expect(dropdownItems.value).toEqual([])
|
||||
expect(isLoading.value).toBe(false)
|
||||
expect(error.value).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -34,27 +34,21 @@ export function useAssetWidgetData(
|
||||
|
||||
const assets = computed<AssetItem[]>(() => {
|
||||
const resolvedType = toValue(nodeType)
|
||||
return resolvedType
|
||||
? (assetsStore.modelAssetsByNodeType.get(resolvedType) ?? [])
|
||||
: []
|
||||
return resolvedType ? (assetsStore.getAssets(resolvedType) ?? []) : []
|
||||
})
|
||||
|
||||
const isLoading = computed(() => {
|
||||
const resolvedType = toValue(nodeType)
|
||||
return resolvedType
|
||||
? (assetsStore.modelLoadingByNodeType.get(resolvedType) ?? false)
|
||||
: false
|
||||
return resolvedType ? assetsStore.isModelLoading(resolvedType) : false
|
||||
})
|
||||
|
||||
const error = computed<Error | null>(() => {
|
||||
const resolvedType = toValue(nodeType)
|
||||
return resolvedType
|
||||
? (assetsStore.modelErrorByNodeType.get(resolvedType) ?? null)
|
||||
: null
|
||||
return resolvedType ? (assetsStore.getError(resolvedType) ?? null) : null
|
||||
})
|
||||
|
||||
const dropdownItems = computed<DropdownItem[]>(() => {
|
||||
return assets.value.map((asset) => ({
|
||||
return (assets.value ?? []).map((asset) => ({
|
||||
id: asset.id,
|
||||
name:
|
||||
(asset.user_metadata?.filename as string | undefined) ?? asset.name,
|
||||
@@ -71,9 +65,10 @@ export function useAssetWidgetData(
|
||||
return
|
||||
}
|
||||
|
||||
const hasData = assetsStore.modelAssetsByNodeType.has(currentNodeType)
|
||||
const isLoading = assetsStore.isModelLoading(currentNodeType)
|
||||
const hasBeenInitialized = assetsStore.hasAssetKey(currentNodeType)
|
||||
|
||||
if (!hasData) {
|
||||
if (!isLoading && !hasBeenInitialized) {
|
||||
await assetsStore.updateModelsForNodeType(currentNodeType)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IBoundingBoxWidget,
|
||||
IImageCropWidget,
|
||||
INumericWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import type {
|
||||
BoundingBoxInputSpec,
|
||||
InputSpec as InputSpecV2
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
function isBoundingBoxLikeWidget(
|
||||
widget: IBaseWidget
|
||||
): widget is IBoundingBoxWidget | IImageCropWidget {
|
||||
return widget.type === 'boundingbox' || widget.type === 'imagecrop'
|
||||
}
|
||||
|
||||
function isNumericWidget(widget: IBaseWidget): widget is INumericWidget {
|
||||
return widget.type === 'number'
|
||||
}
|
||||
|
||||
export const useBoundingBoxWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpecV2
|
||||
): IBoundingBoxWidget | IImageCropWidget => {
|
||||
const spec = inputSpec as BoundingBoxInputSpec
|
||||
const { name, component } = spec
|
||||
const defaultValue: Bounds = spec.default ?? {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 512,
|
||||
height: 512
|
||||
}
|
||||
|
||||
const widgetType = component === 'ImageCrop' ? 'imagecrop' : 'boundingbox'
|
||||
|
||||
const fields: (keyof Bounds)[] = ['x', 'y', 'width', 'height']
|
||||
const subWidgets: INumericWidget[] = []
|
||||
|
||||
const rawWidget = node.addWidget(
|
||||
widgetType,
|
||||
name,
|
||||
{ ...defaultValue },
|
||||
null,
|
||||
{
|
||||
serialize: true,
|
||||
canvasOnly: false
|
||||
}
|
||||
)
|
||||
|
||||
if (!isBoundingBoxLikeWidget(rawWidget)) {
|
||||
throw new Error(`Unexpected widget type: ${rawWidget.type}`)
|
||||
}
|
||||
|
||||
const widget = rawWidget
|
||||
|
||||
widget.callback = () => {
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
const field = fields[i]
|
||||
const subWidget = subWidgets[i]
|
||||
if (subWidget) {
|
||||
subWidget.value = widget.value[field]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of fields) {
|
||||
const subWidget = node.addWidget(
|
||||
'number',
|
||||
field,
|
||||
defaultValue[field],
|
||||
function (this: INumericWidget, v: number) {
|
||||
this.value = Math.round(v)
|
||||
widget.value[field] = this.value
|
||||
widget.callback?.(widget.value)
|
||||
},
|
||||
{
|
||||
min: field === 'width' || field === 'height' ? 1 : 0,
|
||||
max: 8192,
|
||||
step: 10,
|
||||
step2: 1,
|
||||
precision: 0,
|
||||
serialize: false,
|
||||
canvasOnly: true
|
||||
}
|
||||
)
|
||||
|
||||
if (!isNumericWidget(subWidget)) {
|
||||
throw new Error(`Unexpected widget type: ${subWidget.type}`)
|
||||
}
|
||||
|
||||
subWidgets.push(subWidget)
|
||||
}
|
||||
|
||||
widget.linkedWidgets = subWidgets
|
||||
|
||||
return widget
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useComboWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useComboWidget'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
// Mock factory using actual type
|
||||
function createMockAssetItem(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'test-asset-id',
|
||||
name: 'test-image.png',
|
||||
asset_hash: 'hash123',
|
||||
size: 1024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
last_access_time: new Date().toISOString(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
// Use vi.hoisted() to ensure mock state is initialized before mocks
|
||||
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
|
||||
const mockUpdateInputs = vi.hoisted(() => vi.fn(() => Promise.resolve()))
|
||||
const mockGetInputName = vi.hoisted(() => vi.fn((hash: string) => hash))
|
||||
const mockAssetsStoreState = vi.hoisted(() => {
|
||||
const inputAssets: AssetItem[] = []
|
||||
return {
|
||||
inputAssets,
|
||||
inputLoading: false
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/widgets', () => ({
|
||||
addValueControlWidgets: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockDistributionState.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/assetsStore', () => ({
|
||||
useAssetsStore: vi.fn(() => ({
|
||||
get inputAssets() {
|
||||
return mockAssetsStoreState.inputAssets
|
||||
},
|
||||
get inputLoading() {
|
||||
return mockAssetsStoreState.inputLoading
|
||||
},
|
||||
updateInputs: mockUpdateInputs,
|
||||
getInputName: mockGetInputName
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockSettingStoreGet = vi.fn(() => false)
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: mockSettingStoreGet
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: vi.fn((key: string) =>
|
||||
key === 'widgets.selectModel' ? 'Select model' : key
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
isAssetBrowserEligible: vi.fn(() => false)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/composables/useAssetBrowserDialog', () => {
|
||||
const mockAssetBrowserDialogShow = vi.fn()
|
||||
return {
|
||||
useAssetBrowserDialog: vi.fn(() => ({
|
||||
show: mockAssetBrowserDialogShow
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
// Test factory functions
|
||||
function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
|
||||
const mockCallback = vi.fn()
|
||||
const widget: IBaseWidget = {
|
||||
type: 'combo',
|
||||
options: {},
|
||||
name: 'testWidget',
|
||||
value: undefined,
|
||||
callback: mockCallback,
|
||||
y: 0,
|
||||
...overrides
|
||||
}
|
||||
return widget
|
||||
}
|
||||
|
||||
function createMockNode(comfyClass = 'TestNode'): LGraphNode {
|
||||
const node = new LGraphNode('TestNode')
|
||||
node.comfyClass = comfyClass
|
||||
|
||||
// Spy on the addWidget method
|
||||
vi.spyOn(node, 'addWidget').mockImplementation(
|
||||
(type, name, value, callback) => {
|
||||
const widget = createMockWidget({ type, name, value })
|
||||
// Store the callback function on the widget for testing
|
||||
if (typeof callback === 'function') {
|
||||
widget.callback = callback
|
||||
}
|
||||
return widget
|
||||
}
|
||||
)
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
function createMockInputSpec(overrides: Partial<InputSpec> = {}): InputSpec {
|
||||
const inputSpec: InputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'testInput',
|
||||
...overrides
|
||||
}
|
||||
return inputSpec
|
||||
}
|
||||
|
||||
describe('useComboWidget', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSettingStoreGet.mockReturnValue(false)
|
||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(false)
|
||||
vi.mocked(useAssetBrowserDialog).mockClear()
|
||||
mockDistributionState.isCloud = false
|
||||
mockAssetsStoreState.inputAssets = []
|
||||
mockAssetsStoreState.inputLoading = false
|
||||
mockUpdateInputs.mockClear()
|
||||
})
|
||||
|
||||
it('should handle undefined spec', () => {
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget()
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({ name: 'inputName' })
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'combo',
|
||||
'inputName',
|
||||
undefined,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
values: []
|
||||
})
|
||||
)
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
it('should create normal combo widget when asset API is disabled', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockSettingStoreGet.mockReturnValue(false) // Asset API disabled
|
||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true) // Widget is eligible
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget()
|
||||
const mockNode = createMockNode('CheckpointLoaderSimple')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'ckpt_name',
|
||||
options: ['model1.safetensors', 'model2.safetensors']
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
expect(widget).toBe(mockWidget)
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'model1.safetensors',
|
||||
expect.any(Function),
|
||||
{ values: ['model1.safetensors', 'model2.safetensors'] }
|
||||
)
|
||||
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
it('should create asset browser widget when API enabled', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockSettingStoreGet.mockReturnValue(true)
|
||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({
|
||||
type: 'asset',
|
||||
name: 'ckpt_name',
|
||||
value: 'model1.safetensors'
|
||||
})
|
||||
const mockNode = createMockNode('CheckpointLoaderSimple')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'ckpt_name',
|
||||
options: ['model1.safetensors', 'model2.safetensors']
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'asset',
|
||||
'ckpt_name',
|
||||
'model1.safetensors',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
|
||||
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
|
||||
'CheckpointLoaderSimple',
|
||||
'ckpt_name'
|
||||
)
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
it('should create asset browser widget when default value provided without options', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockSettingStoreGet.mockReturnValue(true)
|
||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({
|
||||
type: 'asset',
|
||||
name: 'ckpt_name',
|
||||
value: 'fallback.safetensors'
|
||||
})
|
||||
const mockNode = createMockNode('CheckpointLoaderSimple')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'ckpt_name',
|
||||
default: 'fallback.safetensors'
|
||||
// Note: no options array provided
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'asset',
|
||||
'ckpt_name',
|
||||
'fallback.safetensors',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
it('should show Select model when asset widget has undefined current value', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockSettingStoreGet.mockReturnValue(true)
|
||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({
|
||||
type: 'asset',
|
||||
name: 'ckpt_name',
|
||||
value: 'Select model'
|
||||
})
|
||||
const mockNode = createMockNode('CheckpointLoaderSimple')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'ckpt_name'
|
||||
// Note: no default, no options, not remote - getDefaultValue returns undefined
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'asset',
|
||||
'ckpt_name',
|
||||
'Select model', // Should fallback to this instead of undefined
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
describe('cloud input asset mapping', () => {
|
||||
const HASH_FILENAME =
|
||||
'72e786ff2a44d682c4294db0b7098e569832bc394efc6dad644e6ec85a78efb7.png'
|
||||
const HASH_FILENAME_2 =
|
||||
'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456.jpg'
|
||||
|
||||
it.each([
|
||||
{ nodeClass: 'LoadImage', inputName: 'image' },
|
||||
{ nodeClass: 'LoadVideo', inputName: 'video' },
|
||||
{ nodeClass: 'LoadAudio', inputName: 'audio' }
|
||||
])(
|
||||
'should create combo widget with getOptionLabel for $nodeClass in cloud',
|
||||
({ nodeClass, inputName }) => {
|
||||
mockDistributionState.isCloud = true
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({
|
||||
type: 'combo',
|
||||
name: inputName,
|
||||
value: HASH_FILENAME
|
||||
})
|
||||
const mockNode = createMockNode(nodeClass)
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: inputName,
|
||||
options: [HASH_FILENAME, HASH_FILENAME_2]
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'combo',
|
||||
inputName,
|
||||
HASH_FILENAME,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
values: [], // Empty initially, populated dynamically by Proxy
|
||||
getOptionLabel: expect.any(Function)
|
||||
})
|
||||
)
|
||||
expect(widget).toBe(mockWidget)
|
||||
}
|
||||
)
|
||||
|
||||
it("should format option labels using store's getInputName function", () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockGetInputName.mockReturnValue('Beautiful Sunset.png')
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({
|
||||
type: 'combo',
|
||||
name: 'image',
|
||||
value: HASH_FILENAME
|
||||
})
|
||||
const mockNode = createMockNode('LoadImage')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'image',
|
||||
options: [HASH_FILENAME]
|
||||
})
|
||||
|
||||
constructor(mockNode, inputSpec)
|
||||
|
||||
// Extract the injected getOptionLabel function with type narrowing
|
||||
const addWidgetCall = vi.mocked(mockNode.addWidget).mock.calls[0]
|
||||
const options = addWidgetCall[4]
|
||||
|
||||
if (typeof options !== 'object' || !options) {
|
||||
throw new Error('Expected options to be an object')
|
||||
}
|
||||
|
||||
if (!('getOptionLabel' in options)) {
|
||||
throw new Error('Expected options to have getOptionLabel property')
|
||||
}
|
||||
|
||||
if (typeof options.getOptionLabel !== 'function') {
|
||||
throw new Error('Expected getOptionLabel to be a function')
|
||||
}
|
||||
|
||||
// Test that the injected function calls getInputName
|
||||
const result = options.getOptionLabel(HASH_FILENAME)
|
||||
expect(mockGetInputName).toHaveBeenCalledWith(HASH_FILENAME)
|
||||
expect(result).toBe('Beautiful Sunset.png')
|
||||
})
|
||||
|
||||
it('should create normal combo widget for non-input nodes in cloud', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget()
|
||||
const mockNode = createMockNode('SomeOtherNode')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'option',
|
||||
options: [HASH_FILENAME, HASH_FILENAME_2]
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'combo',
|
||||
'option',
|
||||
HASH_FILENAME,
|
||||
expect.any(Function),
|
||||
{ values: [HASH_FILENAME, HASH_FILENAME_2] }
|
||||
)
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
it('should create normal combo widget for LoadImage in OSS', () => {
|
||||
mockDistributionState.isCloud = false
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget()
|
||||
const mockNode = createMockNode('LoadImage')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'image',
|
||||
options: [HASH_FILENAME, HASH_FILENAME_2]
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'combo',
|
||||
'image',
|
||||
HASH_FILENAME,
|
||||
expect.any(Function),
|
||||
{
|
||||
values: [HASH_FILENAME, HASH_FILENAME_2]
|
||||
}
|
||||
)
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
it('should trigger lazy load for cloud input nodes', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockAssetsStoreState.inputAssets = []
|
||||
mockAssetsStoreState.inputLoading = false
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({ type: 'combo' })
|
||||
const mockNode = createMockNode('LoadImage')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'image',
|
||||
options: [HASH_FILENAME]
|
||||
})
|
||||
|
||||
constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockUpdateInputs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not trigger lazy load if assets already loading', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockAssetsStoreState.inputAssets = []
|
||||
mockAssetsStoreState.inputLoading = true
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({ type: 'combo' })
|
||||
const mockNode = createMockNode('LoadImage')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'image',
|
||||
options: [HASH_FILENAME]
|
||||
})
|
||||
|
||||
constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockUpdateInputs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not trigger lazy load if assets already loaded', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockAssetsStoreState.inputAssets = [
|
||||
createMockAssetItem({
|
||||
id: 'asset-123',
|
||||
name: 'image1.png',
|
||||
asset_hash: HASH_FILENAME
|
||||
})
|
||||
]
|
||||
mockAssetsStoreState.inputLoading = false
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({ type: 'combo' })
|
||||
const mockNode = createMockNode('LoadImage')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'image',
|
||||
options: [HASH_FILENAME]
|
||||
})
|
||||
|
||||
constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockUpdateInputs).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,25 +4,29 @@ import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { isAssetWidget, isComboWidget } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IWidgetAssetOptions
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||
import {
|
||||
assetFilenameSchema,
|
||||
assetItemSchema
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type {
|
||||
ComboInputSpec,
|
||||
InputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { BaseDOMWidget } from '@/scripts/domWidget'
|
||||
import { addValueControlWidgets } from '@/scripts/widgets'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { addValueControlWidgets } from '@/scripts/widgets'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
|
||||
@@ -69,6 +73,16 @@ const addMultiSelectWidget = (
|
||||
addWidget(node, widget as BaseDOMWidget<object | string>)
|
||||
// TODO: Add remote support to multi-select widget
|
||||
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3003
|
||||
if (inputSpec.control_after_generate) {
|
||||
widget.linkedWidgets = addValueControlWidgets(
|
||||
node,
|
||||
widget,
|
||||
'fixed',
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(inputSpec)
|
||||
)
|
||||
}
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
@@ -81,55 +95,59 @@ const createAssetBrowserWidget = (
|
||||
const displayLabel = currentValue ?? t('widgets.selectModel')
|
||||
const assetBrowserDialog = useAssetBrowserDialog()
|
||||
|
||||
async function openModal(widget: IBaseWidget) {
|
||||
if (!isAssetWidget(widget)) {
|
||||
throw new Error(`Expected asset widget but received ${widget.type}`)
|
||||
}
|
||||
await assetBrowserDialog.show({
|
||||
nodeType: node.comfyClass || '',
|
||||
inputName: inputSpec.name,
|
||||
currentValue: widget.value,
|
||||
onAssetSelected: (asset) => {
|
||||
const validatedAsset = assetItemSchema.safeParse(asset)
|
||||
|
||||
if (!validatedAsset.success) {
|
||||
console.error(
|
||||
'Invalid asset item:',
|
||||
validatedAsset.error.errors,
|
||||
'Received:',
|
||||
asset
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const filename = getAssetFilename(validatedAsset.data)
|
||||
const validatedFilename = assetFilenameSchema.safeParse(filename)
|
||||
|
||||
if (!validatedFilename.success) {
|
||||
console.error(
|
||||
'Invalid asset filename:',
|
||||
validatedFilename.error.errors,
|
||||
'for asset:',
|
||||
validatedAsset.data.id
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const oldValue = widget.value
|
||||
widget.value = validatedFilename.data
|
||||
node.onWidgetChanged?.(
|
||||
widget.name,
|
||||
validatedFilename.data,
|
||||
oldValue,
|
||||
widget
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
const options: IWidgetAssetOptions = { openModal }
|
||||
|
||||
const widget = node.addWidget(
|
||||
'asset',
|
||||
inputSpec.name,
|
||||
displayLabel,
|
||||
async function (this: IBaseWidget) {
|
||||
if (!isAssetWidget(widget)) {
|
||||
throw new Error(`Expected asset widget but received ${widget.type}`)
|
||||
}
|
||||
await assetBrowserDialog.show({
|
||||
nodeType: node.comfyClass || '',
|
||||
inputName: inputSpec.name,
|
||||
currentValue: widget.value,
|
||||
onAssetSelected: (asset) => {
|
||||
const validatedAsset = assetItemSchema.safeParse(asset)
|
||||
|
||||
if (!validatedAsset.success) {
|
||||
console.error(
|
||||
'Invalid asset item:',
|
||||
validatedAsset.error.errors,
|
||||
'Received:',
|
||||
asset
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const filename = validatedAsset.data.user_metadata?.filename
|
||||
const validatedFilename = assetFilenameSchema.safeParse(filename)
|
||||
|
||||
if (!validatedFilename.success) {
|
||||
console.error(
|
||||
'Invalid asset filename:',
|
||||
validatedFilename.error.errors,
|
||||
'for asset:',
|
||||
validatedAsset.data.id
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const oldValue = widget.value
|
||||
this.value = validatedFilename.data
|
||||
node.onWidgetChanged?.(
|
||||
widget.name,
|
||||
validatedFilename.data,
|
||||
oldValue,
|
||||
widget
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
() => undefined,
|
||||
options
|
||||
)
|
||||
|
||||
return widget
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { _for_testing } from '@/renderer/extensions/vueNodes/widgets/composables/useFloatWidget'
|
||||
|
||||
vi.mock('@/scripts/widgets', () => ({
|
||||
addValueControlWidgets: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
settings: {}
|
||||
})
|
||||
}))
|
||||
|
||||
const { onFloatValueChange } = _for_testing
|
||||
|
||||
describe('useFloatWidget', () => {
|
||||
describe('onFloatValueChange', () => {
|
||||
let widget: any
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset the widget before each test
|
||||
widget = {
|
||||
options: {},
|
||||
value: 0
|
||||
}
|
||||
})
|
||||
|
||||
it('should not round values when round option is not set', () => {
|
||||
widget.options.round = undefined
|
||||
onFloatValueChange.call(widget, 5.7)
|
||||
expect(widget.value).toBe(5.7)
|
||||
})
|
||||
|
||||
it('should round values based on round option', () => {
|
||||
widget.options.round = 0.5
|
||||
onFloatValueChange.call(widget, 5.7)
|
||||
expect(widget.value).toBe(5.5)
|
||||
|
||||
widget.options.round = 0.1
|
||||
onFloatValueChange.call(widget, 5.74)
|
||||
expect(widget.value).toBe(5.7)
|
||||
|
||||
widget.options.round = 1
|
||||
onFloatValueChange.call(widget, 5.7)
|
||||
expect(widget.value).toBe(6)
|
||||
})
|
||||
|
||||
it('should respect min and max constraints after rounding', () => {
|
||||
widget.options.round = 0.5
|
||||
widget.options.min = 1
|
||||
widget.options.max = 5
|
||||
|
||||
// Should round to 1 and respect min
|
||||
onFloatValueChange.call(widget, 0.7)
|
||||
expect(widget.value).toBe(1)
|
||||
|
||||
// Should round to 5.5 but be clamped to max of 5
|
||||
onFloatValueChange.call(widget, 5.3)
|
||||
expect(widget.value).toBe(5)
|
||||
|
||||
// Should round to 3.5 and be within bounds
|
||||
onFloatValueChange.call(widget, 3.6)
|
||||
expect(widget.value).toBe(3.5)
|
||||
})
|
||||
|
||||
it('should handle Number.EPSILON for precision issues', () => {
|
||||
widget.options.round = 0.1
|
||||
|
||||
// Without Number.EPSILON, 1.35 / 0.1 = 13.499999999999998
|
||||
// which would round to 13 * 0.1 = 1.3 instead of 1.4
|
||||
onFloatValueChange.call(widget, 1.35)
|
||||
expect(widget.value).toBeCloseTo(1.4, 10)
|
||||
|
||||
// Test another edge case
|
||||
onFloatValueChange.call(widget, 2.95)
|
||||
expect(widget.value).toBeCloseTo(3, 10)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,8 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { isFloatInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { addValueControlWidget } from '@/scripts/widgets'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
|
||||
function onFloatValueChange(this: INumericWidget, v: number) {
|
||||
const round = this.options.round
|
||||
@@ -55,7 +57,7 @@ export const useFloatWidget = () => {
|
||||
|
||||
/** Assertion {@link inputSpec.default} */
|
||||
const defaultValue = (inputSpec.default as number | undefined) ?? 0
|
||||
return node.addWidget(
|
||||
const widget = node.addWidget(
|
||||
widgetType,
|
||||
inputSpec.name,
|
||||
defaultValue,
|
||||
@@ -73,6 +75,20 @@ export const useFloatWidget = () => {
|
||||
precision
|
||||
}
|
||||
)
|
||||
|
||||
if (inputSpec.control_after_generate) {
|
||||
const controlWidget = addValueControlWidget(
|
||||
node,
|
||||
widget,
|
||||
'fixed',
|
||||
undefined,
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(inputSpec)
|
||||
)
|
||||
widget.linkedWidgets = [controlWidget]
|
||||
}
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
|
||||
@@ -12,6 +12,37 @@ import { calculateImageGrid } from '@/scripts/ui/imagePreview'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { is_all_same_aspect_ratio } from '@/utils/imageUtil'
|
||||
|
||||
/**
|
||||
* Workaround for Chrome GPU bug:
|
||||
* When Chrome is maximized with GPU acceleration and high DPR, calling
|
||||
* drawImage(canvas) + drawImage(img) in the same frame causes severe
|
||||
* performance degradation (FPS drops to 2-10, memory spikes ~18GB).
|
||||
*
|
||||
* Solution: Defer image rendering using queueMicrotask to separate
|
||||
* the two drawImage calls into different tasks.
|
||||
*
|
||||
* Note: As tested, requestAnimationFrame delays rendering to the next frame,
|
||||
* causing visible image flickering. queueMicrotask executes within the same
|
||||
* frame, avoiding flicker while still separating the drawImage calls.
|
||||
*/
|
||||
let deferredImageRenders: Array<() => void> = []
|
||||
let deferredRenderScheduled = false
|
||||
|
||||
function scheduleDeferredImageRender() {
|
||||
if (deferredRenderScheduled) return
|
||||
deferredRenderScheduled = true
|
||||
|
||||
queueMicrotask(() => {
|
||||
const renders = deferredImageRenders
|
||||
deferredImageRenders = []
|
||||
deferredRenderScheduled = false
|
||||
|
||||
for (const render of renders) {
|
||||
render()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const renderPreview = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
node: LGraphNode,
|
||||
@@ -124,13 +155,31 @@ const renderPreview = (
|
||||
const imgWidth = ratio * img.width
|
||||
const imgX = col * cellWidth + shiftX + (cellWidth - imgWidth) / 2
|
||||
|
||||
ctx.drawImage(
|
||||
// Defer image rendering to work around Chrome GPU bug
|
||||
const transform = ctx.getTransform()
|
||||
const filter = ctx.filter
|
||||
const drawParams = {
|
||||
img,
|
||||
imgX + cell_padding,
|
||||
imgY + cell_padding,
|
||||
imgWidth - cell_padding * 2,
|
||||
imgHeight - cell_padding * 2
|
||||
)
|
||||
x: imgX + cell_padding,
|
||||
y: imgY + cell_padding,
|
||||
w: imgWidth - cell_padding * 2,
|
||||
h: imgHeight - cell_padding * 2
|
||||
}
|
||||
deferredImageRenders.push(() => {
|
||||
ctx.save()
|
||||
ctx.setTransform(transform)
|
||||
ctx.filter = filter
|
||||
ctx.drawImage(
|
||||
drawParams.img,
|
||||
drawParams.x,
|
||||
drawParams.y,
|
||||
drawParams.w,
|
||||
drawParams.h
|
||||
)
|
||||
ctx.restore()
|
||||
})
|
||||
scheduleDeferredImageRender()
|
||||
|
||||
if (!compact_mode) {
|
||||
// rectangle cell and border line style
|
||||
ctx.strokeStyle = '#8F8F8F'
|
||||
@@ -167,7 +216,16 @@ const renderPreview = (
|
||||
|
||||
const x = (dw - w) / 2
|
||||
const y = (dh - h) / 2 + shiftY
|
||||
ctx.drawImage(img, x, y, w, h)
|
||||
|
||||
// Defer image rendering to work around Chrome GPU bug
|
||||
const transform = ctx.getTransform()
|
||||
deferredImageRenders.push(() => {
|
||||
ctx.save()
|
||||
ctx.setTransform(transform)
|
||||
ctx.drawImage(img, x, y, w, h)
|
||||
ctx.restore()
|
||||
})
|
||||
scheduleDeferredImageRender()
|
||||
|
||||
// Draw image size text below the image
|
||||
if (allowImageSizeDraw) {
|
||||
@@ -207,14 +265,19 @@ const renderPreview = (
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = fill
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, y, sz, sz, [4])
|
||||
ctx.fill()
|
||||
ctx.fillStyle = textFill
|
||||
ctx.font = '12px Arial'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(text, x + 15, y + 20)
|
||||
deferredImageRenders.push(() => {
|
||||
ctx.save()
|
||||
ctx.setTransform(transform)
|
||||
ctx.fillStyle = fill
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, y, sz, sz, [4])
|
||||
ctx.fill()
|
||||
ctx.fillStyle = textFill
|
||||
ctx.font = '12px Inter, sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(text, x + 15, y + 20)
|
||||
ctx.restore()
|
||||
})
|
||||
|
||||
return isClicking
|
||||
}
|
||||
@@ -305,7 +368,8 @@ export const useImagePreviewWidget = () => {
|
||||
) => {
|
||||
return node.addCustomWidget(
|
||||
new ImagePreviewWidget(node, inputSpec.name, {
|
||||
serialize: false
|
||||
serialize: false,
|
||||
canvasOnly: true
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { _for_testing } from '@/renderer/extensions/vueNodes/widgets/composables/useIntWidget'
|
||||
|
||||
vi.mock('@/scripts/widgets', () => ({
|
||||
addValueControlWidgets: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
settings: {}
|
||||
})
|
||||
}))
|
||||
|
||||
const { onValueChange } = _for_testing
|
||||
|
||||
describe('useIntWidget', () => {
|
||||
describe('onValueChange', () => {
|
||||
let widget: any
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset the widget before each test
|
||||
widget = {
|
||||
options: {},
|
||||
value: 0
|
||||
}
|
||||
})
|
||||
|
||||
it('should round values based on step size', () => {
|
||||
widget.options.step2 = 0.1
|
||||
onValueChange.call(widget, 5.7)
|
||||
expect(widget.value).toBe(5.7)
|
||||
|
||||
widget.options.step2 = 0.5
|
||||
onValueChange.call(widget, 7.3)
|
||||
expect(widget.value).toBe(7.5)
|
||||
|
||||
widget.options.step2 = 1
|
||||
onValueChange.call(widget, 23.4)
|
||||
expect(widget.value).toBe(23)
|
||||
})
|
||||
|
||||
it('should handle undefined step by using default of 1', () => {
|
||||
widget.options.step2 = undefined
|
||||
onValueChange.call(widget, 3.7)
|
||||
expect(widget.value).toBe(4)
|
||||
})
|
||||
|
||||
it('should account for min value offset', () => {
|
||||
widget.options.step2 = 2
|
||||
widget.options.min = 1
|
||||
// 2 valid values between 1.6 are 1 and 3
|
||||
// 1.6 is closer to 1, so it should round to 1
|
||||
onValueChange.call(widget, 1.6)
|
||||
expect(widget.value).toBe(1)
|
||||
})
|
||||
|
||||
it('should handle undefined min by using default of 0', () => {
|
||||
widget.options.step2 = 2
|
||||
widget.options.min = undefined
|
||||
onValueChange.call(widget, 5.7)
|
||||
expect(widget.value).toBe(6)
|
||||
})
|
||||
|
||||
it('should handle NaN shift value', () => {
|
||||
widget.options.step2 = 0
|
||||
widget.options.min = 1
|
||||
onValueChange.call(widget, 5.7)
|
||||
expect(widget.value).toBe(6)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
import { isIntInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { addValueControlWidget } from '@/scripts/widgets'
|
||||
import { isIntInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { addValueControlWidget } from '@/scripts/widgets'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
|
||||
function onValueChange(this: INumericWidget, v: number) {
|
||||
// For integers, always round to the nearest step
|
||||
@@ -69,14 +69,10 @@ export const useIntWidget = () => {
|
||||
|
||||
const controlAfterGenerate =
|
||||
inputSpec.control_after_generate ??
|
||||
/**
|
||||
* Compatibility with legacy node convention. Int input with name
|
||||
* 'seed' or 'noise_seed' get automatically added a control widget.
|
||||
*/
|
||||
['seed', 'noise_seed'].includes(inputSpec.name)
|
||||
|
||||
if (controlAfterGenerate) {
|
||||
const seedControl = addValueControlWidget(
|
||||
const controlWidget = addValueControlWidget(
|
||||
node,
|
||||
widget,
|
||||
'randomize',
|
||||
@@ -84,7 +80,7 @@ export const useIntWidget = () => {
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(inputSpec)
|
||||
)
|
||||
widget.linkedWidgets = [seedControl]
|
||||
widget.linkedWidgets = [controlWidget]
|
||||
}
|
||||
|
||||
return widget
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
interface NumberWidgetOptions {
|
||||
step?: number
|
||||
step2?: number
|
||||
precision?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared composable for calculating step values in number input widgets
|
||||
* Handles both explicit step2 values and precision-derived steps
|
||||
*/
|
||||
export function useNumberStepCalculation(
|
||||
options: NumberWidgetOptions | undefined,
|
||||
precisionArg: MaybeRefOrGetter<number | undefined>,
|
||||
returnUndefinedForDefault = false
|
||||
) {
|
||||
return computed(() => {
|
||||
const precision = toValue(precisionArg)
|
||||
// Use step2 (correct input spec value) if available
|
||||
if (options?.step2 !== undefined) {
|
||||
return Number(options.step2)
|
||||
}
|
||||
// Use step / 10 for custom large step values (> 10) to match litegraph behavior
|
||||
// This is important for extensions like Impact Pack that use custom step values (e.g., 640)
|
||||
// We skip default step values (1, 10) to avoid affecting normal widgets
|
||||
const step = options?.step
|
||||
if (step !== undefined && step > 10) {
|
||||
return Number(step) / 10
|
||||
}
|
||||
|
||||
if (precision === undefined) {
|
||||
return returnUndefinedForDefault ? undefined : 0
|
||||
}
|
||||
|
||||
if (precision === 0) return 1
|
||||
|
||||
// For precision > 0, step = 1 / (10^precision)
|
||||
const calculatedStep = 1 / Math.pow(10, precision)
|
||||
return returnUndefinedForDefault
|
||||
? calculatedStep
|
||||
: Number(calculatedStep.toFixed(precision))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
import axios from 'axios'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { IWidget } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useRemoteWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget'
|
||||
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
|
||||
|
||||
const createMockNode = (overrides: Partial<LGraphNode> = {}): LGraphNode => {
|
||||
const node = new LGraphNode('TestNode')
|
||||
Object.assign(node, overrides)
|
||||
return node
|
||||
}
|
||||
|
||||
const createMockWidget = (overrides = {}): IWidget =>
|
||||
({ ...overrides }) as unknown as IWidget
|
||||
|
||||
const mockCloudAuth = vi.hoisted(() => ({
|
||||
isCloud: false,
|
||||
authHeader: null as { Authorization: string } | null
|
||||
}))
|
||||
|
||||
vi.mock('axios', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof axios>()
|
||||
return {
|
||||
default: {
|
||||
...actual,
|
||||
get: vi.fn()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockCloudAuth.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', async () => {
|
||||
return {
|
||||
useFirebaseAuthStore: vi.fn(() => ({
|
||||
getAuthHeader: vi.fn(() => Promise.resolve(mockCloudAuth.authHeader))
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', async () => {
|
||||
return {
|
||||
useSettingStore: () => ({
|
||||
settings: {}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const FIRST_BACKOFF = 1000 // backoff is 1s on first retry
|
||||
const DEFAULT_VALUE = 'Loading...'
|
||||
|
||||
function createMockConfig(overrides = {}): RemoteWidgetConfig {
|
||||
return {
|
||||
route: `/api/test/${Date.now()}${Math.random().toString(36).substring(2, 15)}`,
|
||||
refresh: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const createMockOptions = (inputOverrides = {}) => ({
|
||||
remoteConfig: createMockConfig(inputOverrides),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: createMockNode(),
|
||||
widget: createMockWidget()
|
||||
})
|
||||
|
||||
function mockAxiosResponse(data: unknown, status = 200) {
|
||||
vi.mocked(axios.get).mockResolvedValueOnce({ data, status })
|
||||
}
|
||||
|
||||
function mockAxiosError(error: Error | string) {
|
||||
const err = error instanceof Error ? error : new Error(error)
|
||||
vi.mocked(axios.get).mockRejectedValueOnce(err)
|
||||
}
|
||||
|
||||
function createHookWithData(data: unknown, inputOverrides = {}) {
|
||||
mockAxiosResponse(data)
|
||||
const hook = useRemoteWidget(createMockOptions(inputOverrides))
|
||||
return hook
|
||||
}
|
||||
|
||||
async function setupHookWithResponse(data: unknown, inputOverrides = {}) {
|
||||
const hook = createHookWithData(data, inputOverrides)
|
||||
const result = await getResolvedValue(hook)
|
||||
return { hook, result }
|
||||
}
|
||||
|
||||
async function getResolvedValue(hook: ReturnType<typeof useRemoteWidget>) {
|
||||
// Create a promise that resolves when the fetch is complete
|
||||
const responsePromise = new Promise<void>((resolve) => {
|
||||
hook.getValue(() => resolve())
|
||||
})
|
||||
await responsePromise
|
||||
return hook.getCachedValue()
|
||||
}
|
||||
|
||||
describe('useRemoteWidget', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset mocks
|
||||
vi.mocked(axios.get).mockReset()
|
||||
// Reset cache between tests
|
||||
vi.spyOn(Map.prototype, 'get').mockClear()
|
||||
vi.spyOn(Map.prototype, 'set').mockClear()
|
||||
vi.spyOn(Map.prototype, 'delete').mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should create hook with default values', () => {
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
expect(hook.getCachedValue()).toBeUndefined()
|
||||
expect(hook.getValue()).toBe('Loading...')
|
||||
})
|
||||
|
||||
it('should generate consistent cache keys', () => {
|
||||
const options = createMockOptions()
|
||||
const hook1 = useRemoteWidget(options)
|
||||
const hook2 = useRemoteWidget(options)
|
||||
expect(hook1.cacheKey).toBe(hook2.cacheKey)
|
||||
})
|
||||
|
||||
it('should handle query params in cache key', () => {
|
||||
const hook1 = useRemoteWidget(
|
||||
createMockOptions({ query_params: { a: 1 } })
|
||||
)
|
||||
const hook2 = useRemoteWidget(
|
||||
createMockOptions({ query_params: { a: 2 } })
|
||||
)
|
||||
expect(hook1.cacheKey).not.toBe(hook2.cacheKey)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchOptions', () => {
|
||||
it('should fetch data successfully', async () => {
|
||||
const mockData = ['optionA', 'optionB']
|
||||
const { hook, result } = await setupHookWithResponse(mockData)
|
||||
expect(result).toEqual(mockData)
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledWith(
|
||||
hook.cacheKey.split(';')[0], // Get the route part from cache key
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('should use response_key if provided', async () => {
|
||||
const mockResponse = { items: ['optionB', 'optionA', 'optionC'] }
|
||||
const { result } = await setupHookWithResponse(mockResponse, {
|
||||
response_key: 'items'
|
||||
})
|
||||
expect(result).toEqual(mockResponse.items)
|
||||
})
|
||||
|
||||
it('should cache successful responses', async () => {
|
||||
const mockData = ['optionA', 'optionB', 'optionC', 'optionD']
|
||||
const { hook } = await setupHookWithResponse(mockData)
|
||||
const entry = hook.getCacheEntry()
|
||||
|
||||
expect(entry?.data).toEqual(mockData)
|
||||
expect(entry?.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle fetch errors', async () => {
|
||||
const error = new Error('Network error')
|
||||
mockAxiosError(error)
|
||||
|
||||
const { hook } = await setupHookWithResponse([])
|
||||
|
||||
const entry = hook.getCacheEntry()
|
||||
expect(entry?.error).toBeTruthy()
|
||||
expect(entry?.lastErrorTime).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle empty array responses', async () => {
|
||||
const { result } = await setupHookWithResponse([])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle malformed response data', async () => {
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
|
||||
mockAxiosResponse(null)
|
||||
const data1 = hook.getValue()
|
||||
|
||||
mockAxiosResponse(undefined)
|
||||
const data2 = hook.getValue()
|
||||
|
||||
expect(data1).toBe(DEFAULT_VALUE)
|
||||
expect(data2).toBe(DEFAULT_VALUE)
|
||||
})
|
||||
|
||||
it('should handle non-200 status codes', async () => {
|
||||
mockAxiosError('Request failed with status code 404')
|
||||
|
||||
const { hook } = await setupHookWithResponse([])
|
||||
const entry = hook.getCacheEntry()
|
||||
expect(entry?.error?.message).toBe('Request failed with status code 404')
|
||||
})
|
||||
})
|
||||
|
||||
describe('refresh behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('permanent widgets (no refresh)', () => {
|
||||
it('permanent widgets should not attempt fetch after initialization', async () => {
|
||||
const mockData = ['data that is permanent after initialization']
|
||||
const { hook } = await setupHookWithResponse(mockData)
|
||||
|
||||
await getResolvedValue(hook)
|
||||
await getResolvedValue(hook)
|
||||
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('permanent widgets should re-fetch if refreshValue is called', async () => {
|
||||
const mockData = ['data that is permanent after initialization']
|
||||
const { hook } = await setupHookWithResponse(mockData)
|
||||
|
||||
await getResolvedValue(hook)
|
||||
expect(hook.getCachedValue()).toEqual(mockData)
|
||||
|
||||
const refreshedData = ['data that user forced to be fetched']
|
||||
mockAxiosResponse(refreshedData)
|
||||
|
||||
hook.refreshValue()
|
||||
|
||||
// Wait for cache to update with refreshed data
|
||||
await vi.waitFor(() => {
|
||||
expect(hook.getCachedValue()).toEqual(refreshedData)
|
||||
})
|
||||
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('permanent widgets should still retry if request fails', async () => {
|
||||
mockAxiosError('Network error')
|
||||
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
await getResolvedValue(hook)
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.setSystemTime(Date.now() + FIRST_BACKOFF)
|
||||
const secondData = await getResolvedValue(hook)
|
||||
expect(secondData).toBe('Loading...')
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should treat empty refresh field as permanent', async () => {
|
||||
const { hook } = await setupHookWithResponse(['data that is permanent'])
|
||||
|
||||
await getResolvedValue(hook)
|
||||
await getResolvedValue(hook)
|
||||
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should refresh when data is stale', async () => {
|
||||
const refresh = 256
|
||||
const mockData1 = ['option1']
|
||||
const mockData2 = ['option2']
|
||||
|
||||
const { hook } = await setupHookWithResponse(mockData1, { refresh })
|
||||
mockAxiosResponse(mockData2)
|
||||
|
||||
vi.setSystemTime(Date.now() + refresh)
|
||||
const newData = await getResolvedValue(hook)
|
||||
|
||||
expect(newData).toEqual(mockData2)
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should not refresh when data is not stale', async () => {
|
||||
const { hook } = await setupHookWithResponse(['option1'], {
|
||||
refresh: 512
|
||||
})
|
||||
|
||||
vi.setSystemTime(Date.now() + 128)
|
||||
await getResolvedValue(hook)
|
||||
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should use backoff instead of refresh after error', async () => {
|
||||
const refresh = 4096
|
||||
const { hook } = await setupHookWithResponse(['first success'], {
|
||||
refresh
|
||||
})
|
||||
|
||||
mockAxiosError('Network error')
|
||||
vi.setSystemTime(Date.now() + refresh)
|
||||
await getResolvedValue(hook)
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
|
||||
|
||||
mockAxiosResponse(['second success'])
|
||||
vi.setSystemTime(Date.now() + FIRST_BACKOFF)
|
||||
const thirdData = await getResolvedValue(hook)
|
||||
expect(thirdData).toEqual(['second success'])
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should use last valid value after error', async () => {
|
||||
const refresh = 4096
|
||||
const { hook } = await setupHookWithResponse(['a valid value'], {
|
||||
refresh
|
||||
})
|
||||
|
||||
mockAxiosError('Network error')
|
||||
vi.setSystemTime(Date.now() + refresh)
|
||||
const secondData = await getResolvedValue(hook)
|
||||
|
||||
expect(secondData).toEqual(['a valid value'])
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling and backoff', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should implement exponential backoff on errors', async () => {
|
||||
mockAxiosError('Network error')
|
||||
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
await getResolvedValue(hook)
|
||||
const entry1 = hook.getCacheEntry()
|
||||
expect(entry1?.error).toBeTruthy()
|
||||
|
||||
await getResolvedValue(hook)
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.setSystemTime(Date.now() + 500)
|
||||
await getResolvedValue(hook)
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1) // Still backing off
|
||||
|
||||
vi.setSystemTime(Date.now() + 3000)
|
||||
await getResolvedValue(hook)
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
|
||||
expect(entry1?.data).toBeDefined()
|
||||
})
|
||||
|
||||
it('should reset error state on successful fetch', async () => {
|
||||
mockAxiosError('Network error')
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
const firstData = await getResolvedValue(hook)
|
||||
expect(firstData).toBe('Loading...')
|
||||
|
||||
vi.setSystemTime(Date.now() + 3000)
|
||||
mockAxiosResponse(['option1'])
|
||||
const secondData = await getResolvedValue(hook)
|
||||
expect(secondData).toEqual(['option1'])
|
||||
|
||||
const entry = hook.getCacheEntry()
|
||||
expect(entry?.error).toBeNull()
|
||||
expect(entry?.retryCount).toBe(0)
|
||||
})
|
||||
|
||||
it('should save successful data after backoff', async () => {
|
||||
mockAxiosError('Network error')
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
await getResolvedValue(hook)
|
||||
const entry1 = hook.getCacheEntry()
|
||||
expect(entry1?.error).toBeTruthy()
|
||||
|
||||
vi.setSystemTime(Date.now() + 3000)
|
||||
mockAxiosResponse(['success after backoff'])
|
||||
const secondData = await getResolvedValue(hook)
|
||||
expect(secondData).toEqual(['success after backoff'])
|
||||
|
||||
const entry2 = hook.getCacheEntry()
|
||||
expect(entry2?.error).toBeNull()
|
||||
expect(entry2?.retryCount).toBe(0)
|
||||
})
|
||||
|
||||
it('should save successful data after multiple backoffs', async () => {
|
||||
mockAxiosError('Network error')
|
||||
mockAxiosError('Network error')
|
||||
mockAxiosError('Network error')
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
await getResolvedValue(hook)
|
||||
const entry1 = hook.getCacheEntry()
|
||||
expect(entry1?.error).toBeTruthy()
|
||||
|
||||
vi.setSystemTime(Date.now() + 3000)
|
||||
const secondData = await getResolvedValue(hook)
|
||||
expect(secondData).toBe('Loading...')
|
||||
expect(entry1?.error).toBeDefined()
|
||||
|
||||
vi.setSystemTime(Date.now() + 9000)
|
||||
const thirdData = await getResolvedValue(hook)
|
||||
expect(thirdData).toBe('Loading...')
|
||||
expect(entry1?.error).toBeDefined()
|
||||
|
||||
vi.setSystemTime(Date.now() + 120_000)
|
||||
mockAxiosResponse(['success after multiple backoffs'])
|
||||
const fourthData = await getResolvedValue(hook)
|
||||
expect(fourthData).toEqual(['success after multiple backoffs'])
|
||||
|
||||
const entry2 = hook.getCacheEntry()
|
||||
expect(entry2?.error).toBeNull()
|
||||
expect(entry2?.retryCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cache management', () => {
|
||||
it('should clear cache entries', async () => {
|
||||
const { hook } = await setupHookWithResponse(['to be cleared'])
|
||||
expect(hook.getCachedValue()).toBeDefined()
|
||||
|
||||
hook.refreshValue()
|
||||
expect(hook.getCachedValue()).toBe(DEFAULT_VALUE)
|
||||
})
|
||||
|
||||
it('should prevent duplicate in-flight requests', async () => {
|
||||
const mockData = ['non-duplicate']
|
||||
mockAxiosResponse(mockData)
|
||||
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
|
||||
// Start two concurrent getValue calls
|
||||
const promise1 = new Promise<void>((resolve) => {
|
||||
hook.getValue(() => resolve())
|
||||
})
|
||||
const promise2 = new Promise<void>((resolve) => {
|
||||
hook.getValue(() => resolve())
|
||||
})
|
||||
|
||||
// Wait for both e
|
||||
await Promise.all([promise1, promise2])
|
||||
|
||||
// Both should see the same cached data
|
||||
expect(hook.getCachedValue()).toEqual(mockData)
|
||||
// Only one axios call should have been made
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('concurrent access and multiple instances', () => {
|
||||
it('should handle concurrent hook instances with same route', async () => {
|
||||
mockAxiosResponse(['shared data'])
|
||||
const options = createMockOptions()
|
||||
const hook1 = useRemoteWidget(options)
|
||||
const hook2 = useRemoteWidget(options)
|
||||
|
||||
// Since they have the same route, only one request will be made
|
||||
await Promise.race([getResolvedValue(hook1), getResolvedValue(hook2)])
|
||||
|
||||
const data1 = hook1.getValue()
|
||||
const data2 = hook2.getValue()
|
||||
|
||||
expect(data1).toEqual(['shared data'])
|
||||
expect(data2).toEqual(['shared data'])
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
expect(hook1.getCachedValue()).toBe(hook2.getCachedValue())
|
||||
})
|
||||
|
||||
it('should use shared cache across multiple hooks', async () => {
|
||||
mockAxiosResponse(['shared data'])
|
||||
const options = createMockOptions()
|
||||
const hook1 = useRemoteWidget(options)
|
||||
const hook2 = useRemoteWidget(options)
|
||||
const hook3 = useRemoteWidget(options)
|
||||
const hook4 = useRemoteWidget(options)
|
||||
|
||||
const data1 = await getResolvedValue(hook1)
|
||||
const data2 = await getResolvedValue(hook2)
|
||||
const data3 = await getResolvedValue(hook3)
|
||||
const data4 = await getResolvedValue(hook4)
|
||||
|
||||
expect(data1).toEqual(['shared data'])
|
||||
expect(data2).toBe(data1)
|
||||
expect(data3).toBe(data1)
|
||||
expect(data4).toBe(data1)
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
expect(hook1.getCachedValue()).toBe(hook2.getCachedValue())
|
||||
expect(hook2.getCachedValue()).toBe(hook3.getCachedValue())
|
||||
expect(hook3.getCachedValue()).toBe(hook4.getCachedValue())
|
||||
})
|
||||
|
||||
it('should handle rapid cache clearing during fetch', async () => {
|
||||
let resolvePromise: (value: any) => void
|
||||
const delayedPromise = new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
|
||||
vi.mocked(axios.get).mockImplementationOnce(() => delayedPromise as any)
|
||||
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
hook.getValue()
|
||||
hook.refreshValue()
|
||||
|
||||
resolvePromise!({ data: ['delayed data'] })
|
||||
const data = await getResolvedValue(hook)
|
||||
|
||||
// The value should be the default value because the refreshValue
|
||||
// clears the cache and the fetch is aborted
|
||||
expect(data).toEqual(DEFAULT_VALUE)
|
||||
expect(hook.getCachedValue()).toBe(DEFAULT_VALUE)
|
||||
})
|
||||
|
||||
it('should handle widget destroyed during fetch', async () => {
|
||||
let resolvePromise: (value: any) => void
|
||||
const delayedPromise = new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
|
||||
vi.mocked(axios.get).mockImplementationOnce(() => delayedPromise as any)
|
||||
|
||||
let hook = useRemoteWidget(createMockOptions())
|
||||
const fetchPromise = hook.getValue()
|
||||
|
||||
hook = null as any
|
||||
|
||||
resolvePromise!({ data: ['delayed data'] })
|
||||
await fetchPromise
|
||||
|
||||
expect(hook).toBeNull()
|
||||
hook = useRemoteWidget(createMockOptions())
|
||||
|
||||
const data2 = await getResolvedValue(hook)
|
||||
expect(data2).toEqual(DEFAULT_VALUE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloud distribution authentication', () => {
|
||||
describe('when distribution is cloud', () => {
|
||||
describe('when authenticated', () => {
|
||||
it('passes Firebase authentication token in request headers', async () => {
|
||||
const mockData = ['authenticated data']
|
||||
mockCloudAuth.authHeader = null
|
||||
mockCloudAuth.isCloud = true
|
||||
mockCloudAuth.authHeader = { Authorization: 'Bearer test-token' }
|
||||
mockAxiosResponse(mockData)
|
||||
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
await getResolvedValue(hook)
|
||||
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer test-token' }
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when distribution is not cloud', () => {
|
||||
it('bypasses authentication for non-cloud environments', async () => {
|
||||
const mockData = ['non-cloud data']
|
||||
mockCloudAuth.isCloud = false
|
||||
mockAxiosResponse(mockData)
|
||||
|
||||
const hook = useRemoteWidget(createMockOptions())
|
||||
await getResolvedValue(hook)
|
||||
|
||||
const axiosCall = vi.mocked(axios.get).mock.calls[0][1]
|
||||
expect(axiosCall).not.toHaveProperty('headers')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('auto-refresh on task completion', () => {
|
||||
it('should add auto-refresh toggle widget', () => {
|
||||
const mockNode = {
|
||||
addWidget: vi.fn(),
|
||||
widgets: []
|
||||
}
|
||||
const mockWidget = {
|
||||
refresh: vi.fn()
|
||||
}
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode as any,
|
||||
widget: mockWidget as any
|
||||
})
|
||||
|
||||
// Should add auto-refresh toggle widget
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'toggle',
|
||||
'Auto-refresh after generation',
|
||||
false,
|
||||
expect.any(Function),
|
||||
{
|
||||
serialize: false
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should register event listener when enabled', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
const addEventListenerSpy = vi.spyOn(api, 'addEventListener')
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn(),
|
||||
widgets: []
|
||||
}
|
||||
const mockWidget = {
|
||||
refresh: vi.fn()
|
||||
}
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode as any,
|
||||
widget: mockWidget as any
|
||||
})
|
||||
|
||||
// Event listener should be registered immediately
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'execution_success',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('should refresh widget when workflow completes successfully', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
let executionSuccessHandler: (() => void) | undefined
|
||||
|
||||
vi.spyOn(api, 'addEventListener').mockImplementation((event, handler) => {
|
||||
if (event === 'execution_success') {
|
||||
executionSuccessHandler = handler as () => void
|
||||
}
|
||||
})
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn(),
|
||||
widgets: []
|
||||
}
|
||||
const mockWidget = {} as any
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode as any,
|
||||
widget: mockWidget
|
||||
})
|
||||
|
||||
// Spy on the refresh function that was added by useRemoteWidget
|
||||
const refreshSpy = vi.spyOn(mockWidget, 'refresh')
|
||||
|
||||
// Get the toggle callback and enable auto-refresh
|
||||
const toggleCallback = mockNode.addWidget.mock.calls.find(
|
||||
(call) => call[0] === 'toggle'
|
||||
)?.[3]
|
||||
toggleCallback?.(true)
|
||||
|
||||
// Simulate workflow completion
|
||||
executionSuccessHandler?.()
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not refresh when toggle is disabled', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
let executionSuccessHandler: (() => void) | undefined
|
||||
|
||||
vi.spyOn(api, 'addEventListener').mockImplementation((event, handler) => {
|
||||
if (event === 'execution_success') {
|
||||
executionSuccessHandler = handler as () => void
|
||||
}
|
||||
})
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn(),
|
||||
widgets: []
|
||||
}
|
||||
const mockWidget = {} as any
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode as any,
|
||||
widget: mockWidget
|
||||
})
|
||||
|
||||
// Spy on the refresh function that was added by useRemoteWidget
|
||||
const refreshSpy = vi.spyOn(mockWidget, 'refresh')
|
||||
|
||||
// Toggle is disabled by default
|
||||
// Simulate workflow completion
|
||||
executionSuccessHandler?.()
|
||||
|
||||
expect(refreshSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should cleanup event listener on node removal', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
let executionSuccessHandler: (() => void) | undefined
|
||||
|
||||
vi.spyOn(api, 'addEventListener').mockImplementation((event, handler) => {
|
||||
if (event === 'execution_success') {
|
||||
executionSuccessHandler = handler as () => void
|
||||
}
|
||||
})
|
||||
|
||||
const removeEventListenerSpy = vi.spyOn(api, 'removeEventListener')
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn(),
|
||||
widgets: [],
|
||||
onRemoved: undefined as any
|
||||
}
|
||||
const mockWidget = {
|
||||
refresh: vi.fn()
|
||||
}
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode as any,
|
||||
widget: mockWidget as any
|
||||
})
|
||||
|
||||
// Simulate node removal
|
||||
mockNode.onRemoved?.()
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'execution_success',
|
||||
executionSuccessHandler
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import { app } from '@/scripts/app'
|
||||
export const DEFAULT_VIDEO_WIDTH = 640
|
||||
export const DEFAULT_VIDEO_HEIGHT = 480
|
||||
|
||||
// knip-ignore-exports UseWebcamCaptureOptions, UseWebcamCaptureReturn
|
||||
export interface UseWebcamCaptureOptions {
|
||||
videoRef: Ref<HTMLVideoElement | undefined>
|
||||
readonly?: boolean
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
getComponent,
|
||||
isEssential,
|
||||
shouldRenderAsVue,
|
||||
FOR_TESTING
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
|
||||
|
||||
const {
|
||||
WidgetButton,
|
||||
WidgetColorPicker,
|
||||
WidgetInputNumber,
|
||||
WidgetInputText,
|
||||
WidgetMarkdown,
|
||||
WidgetSelect,
|
||||
WidgetTextarea,
|
||||
WidgetToggleSwitch
|
||||
} = FOR_TESTING
|
||||
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
useQueueStore: vi.fn(() => ({
|
||||
historyTasks: []
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the settings store for components that might use it
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn(() => 'before')
|
||||
})
|
||||
}))
|
||||
|
||||
describe('widgetRegistry', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
describe('getComponent', () => {
|
||||
// Test number type mappings
|
||||
describe('number types', () => {
|
||||
it('should map int types to slider widget', () => {
|
||||
expect(getComponent('int')).toBe(WidgetInputNumber)
|
||||
expect(getComponent('INT')).toBe(WidgetInputNumber)
|
||||
})
|
||||
|
||||
it('should map float types to slider widget', () => {
|
||||
expect(getComponent('float')).toBe(WidgetInputNumber)
|
||||
expect(getComponent('FLOAT')).toBe(WidgetInputNumber)
|
||||
expect(getComponent('number')).toBe(WidgetInputNumber)
|
||||
expect(getComponent('slider')).toBe(WidgetInputNumber)
|
||||
})
|
||||
})
|
||||
|
||||
// Test text type mappings
|
||||
describe('text types', () => {
|
||||
it('should map text variations to input text widget', () => {
|
||||
expect(getComponent('text')).toBe(WidgetInputText)
|
||||
expect(getComponent('string')).toBe(WidgetInputText)
|
||||
expect(getComponent('STRING')).toBe(WidgetInputText)
|
||||
})
|
||||
|
||||
it('should map multiline text types to textarea widget', () => {
|
||||
expect(getComponent('multiline')).toBe(WidgetTextarea)
|
||||
expect(getComponent('textarea')).toBe(WidgetTextarea)
|
||||
expect(getComponent('TEXTAREA')).toBe(WidgetTextarea)
|
||||
expect(getComponent('customtext')).toBe(WidgetTextarea)
|
||||
})
|
||||
|
||||
it('should map markdown to markdown widget', () => {
|
||||
expect(getComponent('MARKDOWN')).toBe(WidgetMarkdown)
|
||||
expect(getComponent('markdown')).toBe(WidgetMarkdown)
|
||||
})
|
||||
})
|
||||
|
||||
// Test selection type mappings
|
||||
describe('selection types', () => {
|
||||
it('should map combo types to select widget', () => {
|
||||
expect(getComponent('combo')).toBe(WidgetSelect)
|
||||
expect(getComponent('COMBO')).toBe(WidgetSelect)
|
||||
})
|
||||
})
|
||||
|
||||
// Test boolean type mappings
|
||||
describe('boolean types', () => {
|
||||
it('should map boolean types to toggle switch widget', () => {
|
||||
expect(getComponent('toggle')).toBe(WidgetToggleSwitch)
|
||||
expect(getComponent('boolean')).toBe(WidgetToggleSwitch)
|
||||
expect(getComponent('BOOLEAN')).toBe(WidgetToggleSwitch)
|
||||
})
|
||||
})
|
||||
|
||||
// Test advanced widget mappings
|
||||
describe('advanced widgets', () => {
|
||||
it('should map color types to color picker widget', () => {
|
||||
expect(getComponent('color')).toBe(WidgetColorPicker)
|
||||
expect(getComponent('COLOR')).toBe(WidgetColorPicker)
|
||||
})
|
||||
|
||||
it('should map button types to button widget', () => {
|
||||
expect(getComponent('button')).toBe(WidgetButton)
|
||||
expect(getComponent('BUTTON')).toBe(WidgetButton)
|
||||
})
|
||||
})
|
||||
|
||||
// Test fallback behavior
|
||||
describe('fallback behavior', () => {
|
||||
it('should return null for unknown types', () => {
|
||||
expect(getComponent('unknown')).toBe(null)
|
||||
expect(getComponent('custom_widget')).toBe(null)
|
||||
expect(getComponent('')).toBe(null)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldRenderAsVue', () => {
|
||||
it('should return false for widgets marked as canvas-only', () => {
|
||||
const widget = { type: 'text', options: { canvasOnly: true } }
|
||||
expect(shouldRenderAsVue(widget)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for widgets without a type', () => {
|
||||
const widget = { options: {} }
|
||||
expect(shouldRenderAsVue(widget)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true for widgets with mapped types', () => {
|
||||
expect(shouldRenderAsVue({ type: 'text' })).toBe(true)
|
||||
expect(shouldRenderAsVue({ type: 'int' })).toBe(true)
|
||||
expect(shouldRenderAsVue({ type: 'combo' })).toBe(true)
|
||||
})
|
||||
|
||||
it('should respect options while checking type', () => {
|
||||
const widget: Partial<SafeWidgetData> = {
|
||||
type: 'text',
|
||||
options: { precision: 5 }
|
||||
}
|
||||
expect(shouldRenderAsVue(widget)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isEssential', () => {
|
||||
it('should identify essential widget types', () => {
|
||||
expect(isEssential('int')).toBe(true)
|
||||
expect(isEssential('INT')).toBe(true)
|
||||
expect(isEssential('float')).toBe(true)
|
||||
expect(isEssential('FLOAT')).toBe(true)
|
||||
expect(isEssential('boolean')).toBe(true)
|
||||
expect(isEssential('BOOLEAN')).toBe(true)
|
||||
expect(isEssential('combo')).toBe(true)
|
||||
expect(isEssential('COMBO')).toBe(true)
|
||||
})
|
||||
|
||||
it('should identify non-essential widget types', () => {
|
||||
expect(isEssential('button')).toBe(false)
|
||||
expect(isEssential('color')).toBe(false)
|
||||
expect(isEssential('chart')).toBe(false)
|
||||
expect(isEssential('fileupload')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for unknown types', () => {
|
||||
expect(isEssential('unknown')).toBe(false)
|
||||
expect(isEssential('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle widgets with empty options', () => {
|
||||
const widget = { type: 'text', options: {} }
|
||||
expect(shouldRenderAsVue(widget)).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle case sensitivity correctly through aliases', () => {
|
||||
// Test that both lowercase and uppercase work
|
||||
expect(getComponent('string')).toBe(WidgetInputText)
|
||||
expect(getComponent('STRING')).toBe(WidgetInputText)
|
||||
expect(getComponent('combo')).toBe(WidgetSelect)
|
||||
expect(getComponent('COMBO')).toBe(WidgetSelect)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user