mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
[feat] Add dynamic pricing for API nodes with real-time updates (#3963)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import _ from 'lodash'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import { useComputedWithWidgetWatch } from '@/composables/node/useWatchWidget'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
@@ -111,10 +112,15 @@ export const useNodeBadge = () => {
|
||||
node.badges.push(() => badge.value)
|
||||
|
||||
if (node.constructor.nodeData?.api_node && showApiPricingBadge.value) {
|
||||
const price = nodePricing.getNodeDisplayPrice(node)
|
||||
// Always add the badge for API nodes, with or without price text
|
||||
const creditsBadge = computed(() => {
|
||||
// Use dynamic background color based on the theme
|
||||
// Get the pricing function to determine if this node has dynamic pricing
|
||||
const pricingConfig = nodePricing.getNodePricingConfig(node)
|
||||
const hasDynamicPricing =
|
||||
typeof pricingConfig?.displayPrice === 'function'
|
||||
|
||||
let creditsBadge
|
||||
const createBadge = () => {
|
||||
const price = nodePricing.getNodeDisplayPrice(node)
|
||||
|
||||
const isLightTheme =
|
||||
colorPaletteStore.completedActivePalette.light_theme
|
||||
return new LGraphBadge({
|
||||
@@ -137,7 +143,24 @@ export const useNodeBadge = () => {
|
||||
? adjustColor('#8D6932', { lightness: 0.5 })
|
||||
: '#8D6932'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (hasDynamicPricing) {
|
||||
// For dynamic pricing nodes, use computed that watches widget changes
|
||||
const relevantWidgetNames = nodePricing.getRelevantWidgetNames(
|
||||
node.constructor.nodeData?.name
|
||||
)
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(node, {
|
||||
widgetNames: relevantWidgetNames,
|
||||
triggerCanvasRedraw: true
|
||||
})
|
||||
|
||||
creditsBadge = computedWithWidgetWatch(createBadge)
|
||||
} else {
|
||||
// For static pricing nodes, use regular computed
|
||||
creditsBadge = computed(createBadge)
|
||||
}
|
||||
|
||||
node.badges.push(() => creditsBadge.value)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
85
src/composables/node/useWatchWidget.ts
Normal file
85
src/composables/node/useWatchWidget.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { computedWithControl } from '@vueuse/core'
|
||||
import { type ComputedRef, ref } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
|
||||
export interface UseComputedWithWidgetWatchOptions {
|
||||
/**
|
||||
* Names of widgets to observe for changes.
|
||||
* If not provided, all widgets will be observed.
|
||||
*/
|
||||
widgetNames?: string[]
|
||||
|
||||
/**
|
||||
* Whether to trigger a canvas redraw when widget values change.
|
||||
* @default false
|
||||
*/
|
||||
triggerCanvasRedraw?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A composable that creates a computed that has a node's widget values as a dependencies.
|
||||
* Essentially `computedWithControl` (https://vueuse.org/shared/computedWithControl/) where
|
||||
* the explicitly defined extra dependencies are LGraphNode widgets.
|
||||
*
|
||||
* @param node - The LGraphNode whose widget values are to be watched
|
||||
* @param options - Configuration options for the watcher
|
||||
* @returns A function to create computed that responds to widget changes
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const computedWithWidgetWatch = useComputedWithWidgetWatch(node, {
|
||||
* widgetNames: ['width', 'height'],
|
||||
* triggerCanvasRedraw: true
|
||||
* })
|
||||
*
|
||||
* const dynamicPrice = computedWithWidgetWatch(() => {
|
||||
* return calculatePrice(node)
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export const useComputedWithWidgetWatch = (
|
||||
node: LGraphNode,
|
||||
options: UseComputedWithWidgetWatchOptions = {}
|
||||
) => {
|
||||
const { widgetNames, triggerCanvasRedraw = false } = options
|
||||
|
||||
// Create a reactive trigger based on widget values
|
||||
const widgetValues = ref<Record<string, any>>({})
|
||||
|
||||
// Initialize widget observers
|
||||
if (node.widgets) {
|
||||
const widgetsToObserve = widgetNames
|
||||
? node.widgets.filter((widget) => widgetNames.includes(widget.name))
|
||||
: node.widgets
|
||||
|
||||
// Initialize current values
|
||||
const currentValues: Record<string, any> = {}
|
||||
widgetsToObserve.forEach((widget) => {
|
||||
currentValues[widget.name] = widget.value
|
||||
})
|
||||
widgetValues.value = currentValues
|
||||
|
||||
widgetsToObserve.forEach((widget) => {
|
||||
widget.callback = useChainCallback(widget.callback, () => {
|
||||
// Update the reactive widget values
|
||||
widgetValues.value = {
|
||||
...widgetValues.value,
|
||||
[widget.name]: widget.value
|
||||
}
|
||||
|
||||
// Optionally trigger a canvas redraw
|
||||
if (triggerCanvasRedraw) {
|
||||
node.graph?.setDirtyCanvas(true, true)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Returns a function that creates a computed that responds to widget changes.
|
||||
// The computed will be re-evaluated whenever any observed widget changes.
|
||||
return <T>(computeFn: () => T): ComputedRef<T> => {
|
||||
return computedWithControl(widgetValues, computeFn)
|
||||
}
|
||||
}
|
||||
802
tests-ui/tests/composables/node/useNodePricing.test.ts
Normal file
802
tests-ui/tests/composables/node/useNodePricing.test.ts
Normal file
@@ -0,0 +1,802 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
|
||||
// Helper function to create a mock node
|
||||
function createMockNode(
|
||||
nodeTypeName: string,
|
||||
widgets: Array<{ name: string; value: any }> = [],
|
||||
isApiNode = true
|
||||
): LGraphNode {
|
||||
const mockWidgets = widgets.map(({ name, value }) => ({
|
||||
name,
|
||||
value,
|
||||
type: 'combo'
|
||||
})) as IComboWidget[]
|
||||
|
||||
return {
|
||||
id: Math.random().toString(),
|
||||
widgets: mockWidgets,
|
||||
constructor: {
|
||||
nodeData: {
|
||||
name: nodeTypeName,
|
||||
api_node: isApiNode
|
||||
}
|
||||
}
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
describe('useNodePricing', () => {
|
||||
describe('static pricing', () => {
|
||||
it('should return static price for FluxProCannyNode', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('FluxProCannyNode')
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.05/Run')
|
||||
})
|
||||
|
||||
it('should return static price for StabilityStableImageUltraNode', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('StabilityStableImageUltraNode')
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.08/Run')
|
||||
})
|
||||
|
||||
it('should return empty string for non-API nodes', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('RegularNode', [], false)
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty string for unknown node types', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('UnknownAPINode')
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - KlingTextToVideoNode', () => {
|
||||
it('should return high price for kling-v2-master model', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingTextToVideoNode', [
|
||||
{ name: 'mode', value: 'standard / 5s / v2-master' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$1.40/Run')
|
||||
})
|
||||
|
||||
it('should return standard price for kling-v1-6 model', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingTextToVideoNode', [
|
||||
{ name: 'mode', value: 'standard / 5s / v1-6' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.28/Run')
|
||||
})
|
||||
|
||||
it('should return range when mode widget is missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingTextToVideoNode', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - KlingImage2VideoNode', () => {
|
||||
it('should return high price for kling-v2-master model', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingImage2VideoNode', [
|
||||
{ name: 'model_name', value: 'v2-master' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$1.40/Run')
|
||||
})
|
||||
|
||||
it('should return standard price for kling-v1-6 model', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingImage2VideoNode', [
|
||||
{ name: 'model_name', value: 'v1-6' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.28/Run')
|
||||
})
|
||||
|
||||
it('should return range when model_name widget is missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingImage2VideoNode', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - OpenAIDalle3', () => {
|
||||
it('should return $0.04 for 1024x1024 standard quality', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle3', [
|
||||
{ name: 'size', value: '1024x1024' },
|
||||
{ name: 'quality', value: 'standard' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.04/Run')
|
||||
})
|
||||
|
||||
it('should return $0.08 for 1024x1024 hd quality', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle3', [
|
||||
{ name: 'size', value: '1024x1024' },
|
||||
{ name: 'quality', value: 'hd' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.08/Run')
|
||||
})
|
||||
|
||||
it('should return $0.08 for 1792x1024 standard quality', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle3', [
|
||||
{ name: 'size', value: '1792x1024' },
|
||||
{ name: 'quality', value: 'standard' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.08/Run')
|
||||
})
|
||||
|
||||
it('should return $0.16 for 1792x1024 hd quality', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle3', [
|
||||
{ name: 'size', value: '1792x1024' },
|
||||
{ name: 'quality', value: 'hd' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.12/Run')
|
||||
})
|
||||
|
||||
it('should return $0.08 for 1024x1792 standard quality', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle3', [
|
||||
{ name: 'size', value: '1024x1792' },
|
||||
{ name: 'quality', value: 'standard' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.08/Run')
|
||||
})
|
||||
|
||||
it('should return $0.16 for 1024x1792 hd quality', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle3', [
|
||||
{ name: 'size', value: '1024x1792' },
|
||||
{ name: 'quality', value: 'hd' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.12/Run')
|
||||
})
|
||||
|
||||
it('should return range when widgets are missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle3', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.04-0.12/Run (varies with size & quality)')
|
||||
})
|
||||
|
||||
it('should return range when size widget is missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle3', [
|
||||
{ name: 'quality', value: 'hd' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.04-0.12/Run (varies with size & quality)')
|
||||
})
|
||||
|
||||
it('should return range when quality widget is missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle3', [
|
||||
{ name: 'size', value: '1024x1024' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.04-0.12/Run (varies with size & quality)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - OpenAIDalle2', () => {
|
||||
it('should return $0.02 for 1024x1024 size', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle2', [
|
||||
{ name: 'size', value: '1024x1024' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.02/Run')
|
||||
})
|
||||
|
||||
it('should return $0.018 for 512x512 size', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle2', [
|
||||
{ name: 'size', value: '512x512' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.018/Run')
|
||||
})
|
||||
|
||||
it('should return $0.016 for 256x256 size', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle2', [
|
||||
{ name: 'size', value: '256x256' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.016/Run')
|
||||
})
|
||||
|
||||
it('should return range when size widget is missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle2', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.016-0.02/Run (varies with size)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - OpenAIGPTImage1', () => {
|
||||
it('should return high price range for high quality', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIGPTImage1', [
|
||||
{ name: 'quality', value: 'high' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.167-0.30/Run')
|
||||
})
|
||||
|
||||
it('should return medium price range for medium quality', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIGPTImage1', [
|
||||
{ name: 'quality', value: 'medium' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.046-0.07/Run')
|
||||
})
|
||||
|
||||
it('should return low price range for low quality', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIGPTImage1', [
|
||||
{ name: 'quality', value: 'low' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.011-0.02/Run')
|
||||
})
|
||||
|
||||
it('should return range when quality widget is missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIGPTImage1', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.011-0.30/Run (varies with quality)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - IdeogramV3', () => {
|
||||
it('should return $0.08 for Quality rendering speed', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('IdeogramV3', [
|
||||
{ name: 'rendering_speed', value: 'Quality' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.08/Run')
|
||||
})
|
||||
|
||||
it('should return $0.06 for Balanced rendering speed', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('IdeogramV3', [
|
||||
{ name: 'rendering_speed', value: 'Balanced' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.06/Run')
|
||||
})
|
||||
|
||||
it('should return $0.03 for Turbo rendering speed', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('IdeogramV3', [
|
||||
{ name: 'rendering_speed', value: 'Turbo' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.03/Run')
|
||||
})
|
||||
|
||||
it('should return range when rendering_speed widget is missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('IdeogramV3', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.03-0.08/Run (varies with rendering speed)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - VeoVideoGenerationNode', () => {
|
||||
it('should return $5.00 for 10s duration', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('VeoVideoGenerationNode', [
|
||||
{ name: 'duration_seconds', value: '10' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$5.00/Run')
|
||||
})
|
||||
|
||||
it('should return $2.50 for 5s duration', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('VeoVideoGenerationNode', [
|
||||
{ name: 'duration_seconds', value: '5' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$2.50/Run')
|
||||
})
|
||||
|
||||
it('should return range when duration widget is missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('VeoVideoGenerationNode', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$2.50-5.0/Run (varies with duration)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - LumaVideoNode', () => {
|
||||
it('should return $2.19 for ray-flash-2 4K 5s', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('LumaVideoNode', [
|
||||
{ name: 'model', value: 'ray-flash-2' },
|
||||
{ name: 'resolution', value: '4K' },
|
||||
{ name: 'duration', value: '5s' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$2.19/Run')
|
||||
})
|
||||
|
||||
it('should return $6.37 for ray-2 4K 5s', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('LumaVideoNode', [
|
||||
{ name: 'model', value: 'ray-2' },
|
||||
{ name: 'resolution', value: '4K' },
|
||||
{ name: 'duration', value: '5s' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$6.37/Run')
|
||||
})
|
||||
|
||||
it('should return $0.35 for ray-1-6 model', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('LumaVideoNode', [
|
||||
{ name: 'model', value: 'ray-1-6' },
|
||||
{ name: 'resolution', value: '1080p' },
|
||||
{ name: 'duration', value: '5s' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.35/Run')
|
||||
})
|
||||
|
||||
it('should return range when widgets are missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('LumaVideoNode', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe(
|
||||
'$0.14-11.47/Run (varies with model, resolution & duration)'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - PixverseTextToVideoNode', () => {
|
||||
it('should return range for 5s 1080p quality', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PixverseTextToVideoNode', [
|
||||
{ name: 'duration', value: '5s' },
|
||||
{ name: 'quality', value: '1080p' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe(
|
||||
'$0.45-1.2/Run (varies with duration, quality & motion mode)'
|
||||
)
|
||||
})
|
||||
|
||||
it('should return range for 5s 540p normal quality', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PixverseTextToVideoNode', [
|
||||
{ name: 'duration', value: '5s' },
|
||||
{ name: 'quality', value: '540p' },
|
||||
{ name: 'motion_mode', value: 'normal' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe(
|
||||
'$0.45-1.2/Run (varies with duration, quality & motion mode)'
|
||||
)
|
||||
})
|
||||
|
||||
it('should return range when widgets are missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PixverseTextToVideoNode', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe(
|
||||
'$0.45-1.2/Run (varies with duration, quality & motion mode)'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - KlingDualCharacterVideoEffectNode', () => {
|
||||
it('should return range for v2-master 5s mode', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingDualCharacterVideoEffectNode', [
|
||||
{ name: 'mode', value: 'standard / 5s / v2-master' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)')
|
||||
})
|
||||
|
||||
it('should return range for v1-6 5s mode', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingDualCharacterVideoEffectNode', [
|
||||
{ name: 'mode', value: 'standard / 5s / v1-6' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)')
|
||||
})
|
||||
|
||||
it('should return range when mode widget is missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingDualCharacterVideoEffectNode', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - KlingSingleImageVideoEffectNode', () => {
|
||||
it('should return $0.28 for fuzzyfuzzy effect', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingSingleImageVideoEffectNode', [
|
||||
{ name: 'effect_scene', value: 'fuzzyfuzzy' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.28/Run')
|
||||
})
|
||||
|
||||
it('should return $0.49 for dizzydizzy effect', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingSingleImageVideoEffectNode', [
|
||||
{ name: 'effect_scene', value: 'dizzydizzy' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.49/Run')
|
||||
})
|
||||
|
||||
it('should return range when effect_scene widget is missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingSingleImageVideoEffectNode', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.28-0.49/Run (varies with effect scene)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - PikaImageToVideoNode2_2', () => {
|
||||
it('should return $0.45 for 5s 1080p', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PikaImageToVideoNode2_2', [
|
||||
{ name: 'duration', value: '5s' },
|
||||
{ name: 'resolution', value: '1080p' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.45/Run')
|
||||
})
|
||||
|
||||
it('should return $0.2 for 5s 720p', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PikaImageToVideoNode2_2', [
|
||||
{ name: 'duration', value: '5s' },
|
||||
{ name: 'resolution', value: '720p' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.2/Run')
|
||||
})
|
||||
|
||||
it('should return $1.0 for 10s 1080p', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PikaImageToVideoNode2_2', [
|
||||
{ name: 'duration', value: '10s' },
|
||||
{ name: 'resolution', value: '1080p' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$1.0/Run')
|
||||
})
|
||||
|
||||
it('should return range when widgets are missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PikaImageToVideoNode2_2', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.2-1.0/Run (varies with duration & resolution)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - PikaScenesV2_2', () => {
|
||||
it('should return $0.3 for 5s 720p', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PikaScenesV2_2', [
|
||||
{ name: 'duration', value: '5s' },
|
||||
{ name: 'resolution', value: '720p' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.3/Run')
|
||||
})
|
||||
|
||||
it('should return $0.25 for 10s 720p', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PikaScenesV2_2', [
|
||||
{ name: 'duration', value: '10s' },
|
||||
{ name: 'resolution', value: '720p' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.25/Run')
|
||||
})
|
||||
|
||||
it('should return range when widgets are missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PikaScenesV2_2', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.2-1.0/Run (varies with duration & resolution)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - PikaStartEndFrameNode2_2', () => {
|
||||
it('should return $0.2 for 5s 720p', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PikaStartEndFrameNode2_2', [
|
||||
{ name: 'duration', value: '5s' },
|
||||
{ name: 'resolution', value: '720p' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.2/Run')
|
||||
})
|
||||
|
||||
it('should return $1.0 for 10s 1080p', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PikaStartEndFrameNode2_2', [
|
||||
{ name: 'duration', value: '10s' },
|
||||
{ name: 'resolution', value: '1080p' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$1.0/Run')
|
||||
})
|
||||
|
||||
it('should return range when widgets are missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PikaStartEndFrameNode2_2', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.2-1.0/Run (varies with duration & resolution)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should gracefully handle errors in dynamic pricing functions', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
// Create a node with malformed widget data that could cause errors
|
||||
const node = {
|
||||
id: 'test-node',
|
||||
widgets: null, // This could cause errors when accessing .find()
|
||||
constructor: {
|
||||
nodeData: {
|
||||
name: 'KlingTextToVideoNode',
|
||||
api_node: true
|
||||
}
|
||||
}
|
||||
} as unknown as LGraphNode
|
||||
|
||||
// Should not throw an error and return empty string as fallback
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)')
|
||||
})
|
||||
|
||||
it('should handle completely broken widget structure', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
// Create a node with no widgets property at all
|
||||
const node = {
|
||||
id: 'test-node',
|
||||
// No widgets property
|
||||
constructor: {
|
||||
nodeData: {
|
||||
name: 'OpenAIDalle3',
|
||||
api_node: true
|
||||
}
|
||||
}
|
||||
} as unknown as LGraphNode
|
||||
|
||||
// Should gracefully fall back to the default range
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.04-0.12/Run (varies with size & quality)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('helper methods', () => {
|
||||
describe('getNodePricingConfig', () => {
|
||||
it('should return pricing config for known API nodes', () => {
|
||||
const { getNodePricingConfig } = useNodePricing()
|
||||
const node = createMockNode('KlingTextToVideoNode')
|
||||
|
||||
const config = getNodePricingConfig(node)
|
||||
expect(config).toBeDefined()
|
||||
expect(typeof config.displayPrice).toBe('function')
|
||||
})
|
||||
|
||||
it('should return undefined for unknown nodes', () => {
|
||||
const { getNodePricingConfig } = useNodePricing()
|
||||
const node = createMockNode('UnknownNode')
|
||||
|
||||
const config = getNodePricingConfig(node)
|
||||
expect(config).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRelevantWidgetNames', () => {
|
||||
it('should return correct widget names for KlingTextToVideoNode', () => {
|
||||
const { getRelevantWidgetNames } = useNodePricing()
|
||||
|
||||
const widgetNames = getRelevantWidgetNames('KlingTextToVideoNode')
|
||||
expect(widgetNames).toEqual(['mode', 'model_name', 'duration'])
|
||||
})
|
||||
|
||||
it('should return correct widget names for KlingImage2VideoNode', () => {
|
||||
const { getRelevantWidgetNames } = useNodePricing()
|
||||
|
||||
const widgetNames = getRelevantWidgetNames('KlingImage2VideoNode')
|
||||
expect(widgetNames).toEqual(['mode', 'model_name', 'duration'])
|
||||
})
|
||||
|
||||
it('should return correct widget names for OpenAIDalle3', () => {
|
||||
const { getRelevantWidgetNames } = useNodePricing()
|
||||
|
||||
const widgetNames = getRelevantWidgetNames('OpenAIDalle3')
|
||||
expect(widgetNames).toEqual(['size', 'quality'])
|
||||
})
|
||||
|
||||
it('should return correct widget names for VeoVideoGenerationNode', () => {
|
||||
const { getRelevantWidgetNames } = useNodePricing()
|
||||
|
||||
const widgetNames = getRelevantWidgetNames('VeoVideoGenerationNode')
|
||||
expect(widgetNames).toEqual(['duration_seconds'])
|
||||
})
|
||||
|
||||
it('should return correct widget names for LumaVideoNode', () => {
|
||||
const { getRelevantWidgetNames } = useNodePricing()
|
||||
|
||||
const widgetNames = getRelevantWidgetNames('LumaVideoNode')
|
||||
expect(widgetNames).toEqual(['model', 'resolution', 'duration'])
|
||||
})
|
||||
|
||||
it('should return correct widget names for KlingSingleImageVideoEffectNode', () => {
|
||||
const { getRelevantWidgetNames } = useNodePricing()
|
||||
|
||||
const widgetNames = getRelevantWidgetNames(
|
||||
'KlingSingleImageVideoEffectNode'
|
||||
)
|
||||
expect(widgetNames).toEqual(['effect_scene'])
|
||||
})
|
||||
|
||||
it('should return correct widget names for PikaImageToVideoNode2_2', () => {
|
||||
const { getRelevantWidgetNames } = useNodePricing()
|
||||
|
||||
const widgetNames = getRelevantWidgetNames('PikaImageToVideoNode2_2')
|
||||
expect(widgetNames).toEqual(['duration', 'resolution'])
|
||||
})
|
||||
|
||||
it('should return empty array for unknown node types', () => {
|
||||
const { getRelevantWidgetNames } = useNodePricing()
|
||||
|
||||
const widgetNames = getRelevantWidgetNames('UnknownNode')
|
||||
expect(widgetNames).toEqual([])
|
||||
})
|
||||
|
||||
describe('Recraft nodes with n parameter', () => {
|
||||
it('should return correct widget names for RecraftTextToImageNode', () => {
|
||||
const { getRelevantWidgetNames } = useNodePricing()
|
||||
|
||||
const widgetNames = getRelevantWidgetNames('RecraftTextToImageNode')
|
||||
expect(widgetNames).toEqual(['n'])
|
||||
})
|
||||
|
||||
it('should return correct widget names for RecraftTextToVectorNode', () => {
|
||||
const { getRelevantWidgetNames } = useNodePricing()
|
||||
|
||||
const widgetNames = getRelevantWidgetNames('RecraftTextToVectorNode')
|
||||
expect(widgetNames).toEqual(['n'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Recraft nodes dynamic pricing', () => {
|
||||
it('should calculate dynamic pricing for RecraftTextToImageNode based on n value', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('RecraftTextToImageNode', [
|
||||
{ name: 'n', value: 3 }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.12/Run') // 0.04 * 3
|
||||
})
|
||||
|
||||
it('should calculate dynamic pricing for RecraftTextToVectorNode based on n value', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('RecraftTextToVectorNode', [
|
||||
{ name: 'n', value: 2 }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.16/Run') // 0.08 * 2
|
||||
})
|
||||
|
||||
it('should fall back to static display when n widget is missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('RecraftTextToImageNode', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.04 x n/Run')
|
||||
})
|
||||
|
||||
it('should handle edge case when n value is 1', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('RecraftImageInpaintingNode', [
|
||||
{ name: 'n', value: 1 }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.04/Run') // 0.04 * 1
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
183
tests-ui/tests/composables/useWatchWidget.test.ts
Normal file
183
tests-ui/tests/composables/useWatchWidget.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { useComputedWithWidgetWatch } from '@/composables/node/useWatchWidget'
|
||||
|
||||
// Mock useChainCallback
|
||||
vi.mock('@/composables/functional/useChainCallback', () => ({
|
||||
useChainCallback: vi.fn((original, newCallback) => {
|
||||
return function (this: any, ...args: any[]) {
|
||||
original?.call(this, ...args)
|
||||
newCallback.call(this, ...args)
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useComputedWithWidgetWatch', () => {
|
||||
const createMockNode = (
|
||||
widgets: Array<{
|
||||
name: string
|
||||
value: any
|
||||
callback?: (...args: any[]) => void
|
||||
}> = []
|
||||
) => {
|
||||
const mockNode = {
|
||||
widgets: widgets.map((widget) => ({
|
||||
name: widget.name,
|
||||
value: widget.value,
|
||||
callback: widget.callback
|
||||
})),
|
||||
graph: {
|
||||
setDirtyCanvas: vi.fn()
|
||||
}
|
||||
} as unknown as LGraphNode
|
||||
|
||||
return mockNode
|
||||
}
|
||||
|
||||
it('should create a reactive computed that responds to widget changes', async () => {
|
||||
const mockNode = createMockNode([
|
||||
{ name: 'width', value: 100 },
|
||||
{ name: 'height', value: 200 }
|
||||
])
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode)
|
||||
|
||||
const computedValue = computedWithWidgetWatch(() => {
|
||||
const width =
|
||||
(mockNode.widgets?.find((w) => w.name === 'width')?.value as number) ||
|
||||
0
|
||||
const height =
|
||||
(mockNode.widgets?.find((w) => w.name === 'height')?.value as number) ||
|
||||
0
|
||||
return width * height
|
||||
})
|
||||
|
||||
// Initial value should be computed correctly
|
||||
expect(computedValue.value).toBe(20000)
|
||||
|
||||
// Change widget value and trigger callback
|
||||
const widthWidget = mockNode.widgets?.find((w) => w.name === 'width')
|
||||
if (widthWidget) {
|
||||
widthWidget.value = 150
|
||||
;(widthWidget.callback as any)?.()
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Computed should update
|
||||
expect(computedValue.value).toBe(30000)
|
||||
})
|
||||
|
||||
it('should only observe specific widgets when widgetNames is provided', async () => {
|
||||
const mockNode = createMockNode([
|
||||
{ name: 'width', value: 100 },
|
||||
{ name: 'height', value: 200 },
|
||||
{ name: 'depth', value: 50 }
|
||||
])
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
|
||||
widgetNames: ['width', 'height']
|
||||
})
|
||||
|
||||
const computedValue = computedWithWidgetWatch(() => {
|
||||
return mockNode.widgets?.map((w) => w.value).join('-') || ''
|
||||
})
|
||||
|
||||
expect(computedValue.value).toBe('100-200-50')
|
||||
|
||||
// Change observed widget
|
||||
const widthWidget = mockNode.widgets?.find((w) => w.name === 'width')
|
||||
if (widthWidget) {
|
||||
widthWidget.value = 150
|
||||
;(widthWidget.callback as any)?.()
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
expect(computedValue.value).toBe('150-200-50')
|
||||
|
||||
// Change non-observed widget - should not trigger update
|
||||
const depthWidget = mockNode.widgets?.find((w) => w.name === 'depth')
|
||||
if (depthWidget) {
|
||||
depthWidget.value = 75
|
||||
// Depth widget callback should not have been modified
|
||||
expect(depthWidget.callback).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('should trigger canvas redraw when triggerCanvasRedraw is true', async () => {
|
||||
const mockNode = createMockNode([{ name: 'value', value: 10 }])
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
|
||||
triggerCanvasRedraw: true
|
||||
})
|
||||
|
||||
computedWithWidgetWatch(() => mockNode.widgets?.[0]?.value || 0)
|
||||
|
||||
// Change widget value
|
||||
const widget = mockNode.widgets?.[0]
|
||||
if (widget) {
|
||||
widget.value = 20
|
||||
;(widget.callback as any)?.()
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Canvas redraw should have been triggered
|
||||
expect(mockNode.graph?.setDirtyCanvas).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('should not trigger canvas redraw when triggerCanvasRedraw is false', async () => {
|
||||
const mockNode = createMockNode([{ name: 'value', value: 10 }])
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
|
||||
triggerCanvasRedraw: false
|
||||
})
|
||||
|
||||
computedWithWidgetWatch(() => mockNode.widgets?.[0]?.value || 0)
|
||||
|
||||
// Change widget value
|
||||
const widget = mockNode.widgets?.[0]
|
||||
if (widget) {
|
||||
widget.value = 20
|
||||
;(widget.callback as any)?.()
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Canvas redraw should not have been triggered
|
||||
expect(mockNode.graph?.setDirtyCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle nodes without widgets gracefully', () => {
|
||||
const mockNode = createMockNode([])
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode)
|
||||
|
||||
const computedValue = computedWithWidgetWatch(() => 'no widgets')
|
||||
|
||||
expect(computedValue.value).toBe('no widgets')
|
||||
})
|
||||
|
||||
it('should chain with existing widget callbacks', async () => {
|
||||
const existingCallback = vi.fn()
|
||||
const mockNode = createMockNode([
|
||||
{ name: 'value', value: 10, callback: existingCallback }
|
||||
])
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode)
|
||||
computedWithWidgetWatch(() => mockNode.widgets?.[0]?.value || 0)
|
||||
|
||||
// Trigger widget callback
|
||||
const widget = mockNode.widgets?.[0]
|
||||
if (widget) {
|
||||
;(widget.callback as any)?.()
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Both existing callback and our callback should have been called
|
||||
expect(existingCallback).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user