import { describe, expect, it } from 'vitest' import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits' import { evaluateNodeDefPricing, formatCreditsListValue, formatCreditsRangeValue, formatCreditsValue, formatPricingResult, useNodePricing } from '@/composables/node/useNodePricing' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import { LiteGraph } from '@/lib/litegraph/src/litegraph' import type { ComfyNodeDef, PriceBadge } from '@/schemas/nodeDefSchema' import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils' // ----------------------------------------------------------------------------- // Test Types // ----------------------------------------------------------------------------- interface MockNodeWidget { name: string value: unknown type: string } interface MockNodeInput { name: string link: number | null } interface MockNodeData { name: string api_node: boolean price_badge?: PriceBadge } // ----------------------------------------------------------------------------- // Test Helpers // ----------------------------------------------------------------------------- /** * Determine if a number should display 1 decimal place. * Shows decimal only when the first decimal digit is non-zero. */ const shouldShowDecimal = (value: number): boolean => { const rounded = Math.round(value * 10) / 10 return rounded % 1 !== 0 } const creditValue = (usd: number): string => { const rawCredits = usd * CREDITS_PER_USD return formatCredits({ value: rawCredits, numberOptions: { minimumFractionDigits: 0, maximumFractionDigits: shouldShowDecimal(rawCredits) ? 1 : 0 } }) } const creditsLabel = (usd: number, suffix = '/Run'): string => `${creditValue(usd)} credits${suffix}` /** * Create a mock node with price_badge for testing JSONata-based pricing. */ function createMockNodeWithPriceBadge( nodeTypeName: string, priceBadge: PriceBadge, widgets: Array<{ name: string; value: unknown }> = [], inputs: Array<{ name: string; connected?: boolean }> = [] ): LGraphNode { const mockWidgets = widgets.map(({ name, value }) => ({ name, value, type: 'combo' })) const mockInputs: MockNodeInput[] = inputs.map(({ name, connected }) => ({ name, link: connected ? 1 : null })) const baseNode = createMockLGraphNode() return Object.assign(baseNode, { widgets: mockWidgets, inputs: mockInputs, constructor: { nodeData: { name: nodeTypeName, api_node: true, price_badge: priceBadge } } }) } /** Helper to create a price badge with defaults */ const priceBadge = ( expr: string, widgets: Array<{ name: string; type: string }> = [], inputs: string[] = [], inputGroups: string[] = [] ): PriceBadge => ({ engine: 'jsonata', expr, depends_on: { widgets, inputs, input_groups: inputGroups } }) /** Helper to create a mock node for edge case testing */ function createMockNode( nodeData: MockNodeData, widgets: MockNodeWidget[] = [], inputs: MockNodeInput[] = [] ): LGraphNode { const baseNode = createMockLGraphNode() return Object.assign(baseNode, { widgets, inputs, constructor: { nodeData } }) } // ----------------------------------------------------------------------------- // Tests // ----------------------------------------------------------------------------- describe('useNodePricing', () => { describe('static expressions', () => { it('should evaluate simple static USD price', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestStaticNode', priceBadge('{"type":"usd","usd":0.05}') ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe(creditsLabel(0.05)) }) it('should evaluate static text result', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestTextNode', priceBadge('{"type":"text","text":"Free"}') ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe('Free') }) }) describe('widget value normalization', () => { it('should handle INT widget as number', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestIntNode', priceBadge('{"type":"usd","usd": widgets.count * 0.01}', [ { name: 'count', type: 'INT' } ]), [{ name: 'count', value: 5 }] ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe(creditsLabel(0.05)) }) it('should handle FLOAT widget as number', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestFloatNode', priceBadge('{"type":"usd","usd": widgets.rate * 10}', [ { name: 'rate', type: 'FLOAT' } ]), [{ name: 'rate', value: 0.05 }] ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe(creditsLabel(0.5)) }) it('should handle COMBO widget with numeric value', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestComboNumericNode', priceBadge('{"type":"usd","usd": widgets.duration * 0.07}', [ { name: 'duration', type: 'COMBO' } ]), [{ name: 'duration', value: 5 }] ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe(creditsLabel(0.35)) }) it('should handle COMBO widget with string value', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestComboStringNode', priceBadge( '(widgets.mode = "pro") ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}', [{ name: 'mode', type: 'COMBO' }] ), [{ name: 'mode', value: 'Pro' }] // Should be lowercased to "pro" ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe(creditsLabel(0.1)) }) it('should handle BOOLEAN widget', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestBooleanNode', priceBadge('{"type":"usd","usd": widgets.premium ? 0.10 : 0.05}', [ { name: 'premium', type: 'BOOLEAN' } ]), [{ name: 'premium', value: true }] ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe(creditsLabel(0.1)) }) it('should handle STRING widget (lowercased)', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestStringNode', priceBadge( '$contains(widgets.model, "pro") ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}', [{ name: 'model', type: 'STRING' }] ), [{ name: 'model', value: 'ProModel' }] // Should be lowercased to "promodel" ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe(creditsLabel(0.1)) }) }) describe('complex expressions', () => { it('should handle lookup tables', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestLookupNode', priceBadge( `( $rates := {"720p": 0.05, "1080p": 0.10}; {"type":"usd","usd": $lookup($rates, widgets.resolution)} )`, [{ name: 'resolution', type: 'COMBO' }] ), [{ name: 'resolution', value: '1080p' }] ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe(creditsLabel(0.1)) }) it('should handle multiple widgets', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestMultiWidgetNode', priceBadge( `( $rate := (widgets.mode = "pro") ? 0.10 : 0.05; {"type":"usd","usd": $rate * widgets.duration} )`, [ { name: 'mode', type: 'COMBO' }, { name: 'duration', type: 'INT' } ] ), [ { name: 'mode', value: 'pro' }, { name: 'duration', value: 10 } ] ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe(creditsLabel(1.0)) }) it('should handle conditional pricing based on widget values', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestConditionalNode', priceBadge( `( $mode := (widgets.resolution = "720p") ? "std" : "pro"; $rates := {"std": 0.084, "pro": 0.112}; {"type":"usd","usd": $lookup($rates, $mode) * widgets.duration} )`, [ { name: 'resolution', type: 'COMBO' }, { name: 'duration', type: 'COMBO' } ] ), [ { name: 'resolution', value: '1080p' }, { name: 'duration', value: 5 } ] ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe(creditsLabel(0.56)) }) }) describe('range and list results', () => { it('should format range_usd result', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestRangeNode', priceBadge('{"type":"range_usd","min_usd":0.05,"max_usd":0.10}') ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toMatch(/\d+\.?\d*-\d+\.?\d* credits\/Run/) }) it('should format list_usd result', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestListNode', priceBadge('{"type":"list_usd","usd":[0.05, 0.10, 0.15]}') ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toMatch(/\d+\.?\d*\/\d+\.?\d*\/\d+\.?\d* credits\/Run/) }) it('should respect custom suffix in format options', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestSuffixNode', priceBadge('{"type":"usd","usd":0.07,"format":{"suffix":"/second"}}') ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe(creditsLabel(0.07, '/second')) }) it('should add approximate prefix when specified', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestApproximateNode', priceBadge('{"type":"usd","usd":0.05,"format":{"approximate":true}}') ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toMatch(/^~\d+\.?\d* credits\/Run$/) }) it('should add note suffix when specified', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestNoteNode', priceBadge('{"type":"usd","usd":0.05,"format":{"note":"(estimated)"}}') ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toMatch(/credits\/Run \(estimated\)$/) }) it('should combine approximate prefix and note suffix', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestCombinedFormatNode', priceBadge( '{"type":"usd","usd":0.05,"format":{"approximate":true,"note":"(beta)","suffix":"/image"}}' ) ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toMatch(/^~\d+\.?\d* credits\/image \(beta\)$/) }) it('should use custom separator for list_usd', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestListSeparatorNode', priceBadge( '{"type":"list_usd","usd":[0.05, 0.10],"format":{"separator":" or "}}' ) ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toMatch(/\d+\.?\d* or \d+\.?\d* credits\/Run/) }) }) describe('input connectivity', () => { it('should handle connected input check', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestInputNode', priceBadge( 'inputs.image.connected ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}', [], ['image'] ), [], [{ name: 'image', connected: true }] ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe(creditsLabel(0.1)) }) it('should handle disconnected input check', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestInputDisconnectedNode', priceBadge( 'inputs.image.connected ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}', [], ['image'] ), [], [{ name: 'image', connected: false }] ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe(creditsLabel(0.05)) }) }) describe('edge cases', () => { it('should return empty string for non-API nodes', () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNode({ name: 'RegularNode', api_node: false }) const price = getNodeDisplayPrice(node) expect(price).toBe('') }) it('should return empty string for nodes without price_badge', () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNode({ name: 'ApiNodeNoPricing', api_node: true }) const price = getNodeDisplayPrice(node) expect(price).toBe('') }) it('should handle null widget value gracefully', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestNullWidgetNode', priceBadge( '{"type":"usd","usd": (widgets.count != null) ? widgets.count * 0.01 : 0.05}', [{ name: 'count', type: 'INT' }] ), [{ name: 'count', value: null }] ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe(creditsLabel(0.05)) }) it('should handle missing widget gracefully', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestMissingWidgetNode', priceBadge( '{"type":"usd","usd": (widgets.count != null) ? widgets.count * 0.01 : 0.05}', [{ name: 'count', type: 'INT' }] ), [] ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe(creditsLabel(0.05)) }) it('should handle undefined widget value gracefully', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestUndefinedWidgetNode', priceBadge( '{"type":"usd","usd": (widgets.count != null) ? widgets.count * 0.01 : 0.05}', [{ name: 'count', type: 'INT' }] ), [{ name: 'count', value: undefined }] ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe(creditsLabel(0.05)) }) }) describe('getNodePricingConfig', () => { it('should return pricing config for nodes with price_badge', () => { const { getNodePricingConfig } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestConfigNode', priceBadge('{"type":"usd","usd":0.05}') ) const config = getNodePricingConfig(node) expect(config).toBeDefined() expect(config?.engine).toBe('jsonata') expect(config?.expr).toBe('{"type":"usd","usd":0.05}') expect(config?.depends_on).toBeDefined() }) it('should return undefined for nodes without price_badge', () => { const { getNodePricingConfig } = useNodePricing() const node = createMockNode({ name: 'NoPricingNode', api_node: true }) const config = getNodePricingConfig(node) expect(config).toBeUndefined() }) it('should return undefined for non-API nodes', () => { const { getNodePricingConfig } = useNodePricing() const node = createMockNode({ name: 'RegularNode', api_node: false }) const config = getNodePricingConfig(node) expect(config).toBeUndefined() }) it('does not leak the compiled JSONata expression', () => { const { getNodePricingConfig } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestStripCompiledNode', priceBadge('{"type":"usd","usd":0.05}') ) const config = getNodePricingConfig(node) expect(config).toBeDefined() // _compiled is the runtime JSONata instance and must not be exposed to // tooling/debug consumers. expect(config).not.toHaveProperty('_compiled') }) }) describe('reactive revision', () => { it('bumps pricingRevision after an async evaluation resolves (Nodes 1.0 mode)', async () => { const { getNodeDisplayPrice, pricingRevision } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestRevisionNode', priceBadge('{"type":"usd","usd":0.05}') ) const before = pricingRevision.value getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) expect(pricingRevision.value).toBeGreaterThan(before) }) it('bumps the per-node revision ref after async evaluation resolves in VueNodes mode', async () => { const { getNodeDisplayPrice, getNodeRevisionRef, pricingRevision } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestVueNodeRevision', priceBadge('{"type":"usd","usd":0.05}') ) LiteGraph.vueNodesMode = true try { const revBefore = getNodeRevisionRef(node.id).value const tickBefore = pricingRevision.value getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) // VueNodes path bumps per-node ref instead of the global tick. expect(getNodeRevisionRef(node.id).value).toBeGreaterThan(revBefore) expect(pricingRevision.value).toBe(tickBefore) } finally { LiteGraph.vueNodesMode = false } }) it('returns the cached label on a second call with the same signature', async () => { const { getNodeDisplayPrice, pricingRevision } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestCachedSignatureNode', priceBadge('{"type":"usd","usd":0.05}') ) // First call schedules eval; second call (after resolution) is a cache hit. getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const first = getNodeDisplayPrice(node) const tickAfterFirst = pricingRevision.value const second = getNodeDisplayPrice(node) // Cache-hit path must not schedule a new evaluation, so no further tick. await new Promise((r) => setTimeout(r, 20)) expect(second).toBe(first) expect(pricingRevision.value).toBe(tickAfterFirst) }) }) describe('getNodeRevisionRef', () => { it('should return a ref for a node ID', () => { const { getNodeRevisionRef } = useNodePricing() const ref = getNodeRevisionRef('node-1') expect(ref).toBeDefined() expect(ref.value).toBe(0) }) it('should return the same ref for the same node ID', () => { const { getNodeRevisionRef } = useNodePricing() const ref1 = getNodeRevisionRef('node-same') const ref2 = getNodeRevisionRef('node-same') expect(ref1).toBe(ref2) }) it('should return different refs for different node IDs', () => { const { getNodeRevisionRef } = useNodePricing() const ref1 = getNodeRevisionRef('node-a') const ref2 = getNodeRevisionRef('node-b') expect(ref1).not.toBe(ref2) }) it('should handle both string and number node IDs', () => { const { getNodeRevisionRef } = useNodePricing() // Number ID gets stringified, so '123' and 123 should return the same ref const refFromNumber = getNodeRevisionRef(123) const refFromString = getNodeRevisionRef('123') expect(refFromNumber).toBe(refFromString) }) }) describe('triggerPriceRecalculation', () => { it('should not throw for API nodes with price_badge', () => { const { triggerPriceRecalculation } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestTriggerNode', priceBadge('{"type":"usd","usd":0.05}') ) expect(() => triggerPriceRecalculation(node)).not.toThrow() }) it('should not throw for non-API nodes', () => { const { triggerPriceRecalculation } = useNodePricing() const node = createMockNode({ name: 'RegularNode', api_node: false }) expect(() => triggerPriceRecalculation(node)).not.toThrow() }) }) describe('error handling', () => { it('should return empty string for invalid JSONata expression', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestInvalidExprNode', // Invalid JSONata syntax (unclosed parenthesis) priceBadge('{"type":"usd","usd": (widgets.count * 0.01') ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) // Should not crash, just return empty expect(price).toBe('') }) it('should return empty string for expression that throws at runtime', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestRuntimeErrorNode', // Expression that will fail at runtime (calling function on undefined) priceBadge('$lookup(undefined, "key")') ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe('') }) it('should return empty string for invalid PricingResult type', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestInvalidResultTypeNode', // Returns object with invalid type field priceBadge('{"type":"invalid_type","value":123}') ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe('') }) it('should handle legacy format without type field', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestLegacyFormatNode', // Returns object without type field (legacy format) priceBadge('{"usd":0.05}') ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) // Legacy format {usd: number} is supported expect(price).toBe(creditsLabel(0.05)) }) it('should return empty string for non-object result', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestNonObjectNode', // Returns a plain number instead of PricingResult object priceBadge('0.05') ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe('') }) it('should return empty string for null result', async () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNodeWithPriceBadge( 'TestNullResultNode', priceBadge('null') ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe('') }) }) describe('input_groups connectivity', () => { it('should count connected inputs in a group', async () => { const { getNodeDisplayPrice } = useNodePricing() // Create a node with autogrow-style inputs (group.input1, group.input2, etc.) const node = createMockNode( { name: 'TestInputGroupNode', api_node: true, price_badge: { engine: 'jsonata', expr: '{"type":"usd","usd": inputGroups.videos * 0.05}', depends_on: { widgets: [], inputs: [], input_groups: ['videos'] } } }, [], [ { name: 'videos.clip1', link: 1 }, // connected { name: 'videos.clip2', link: 2 }, // connected { name: 'videos.clip3', link: null }, // disconnected { name: 'other_input', link: 3 } // connected but not in group ] ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) // 2 connected inputs in 'videos' group * 0.05 = 0.10 expect(price).toBe(creditsLabel(0.1)) }) }) describe('decimal formatting', () => { describe('shouldShowDecimal helper', () => { it('should return true when first decimal digit is non-zero', () => { expect(shouldShowDecimal(10.5)).toBe(true) expect(shouldShowDecimal(10.1)).toBe(true) expect(shouldShowDecimal(10.9)).toBe(true) expect(shouldShowDecimal(1.5)).toBe(true) }) it('should return false for whole numbers', () => { expect(shouldShowDecimal(10)).toBe(false) expect(shouldShowDecimal(10.0)).toBe(false) expect(shouldShowDecimal(1)).toBe(false) expect(shouldShowDecimal(100)).toBe(false) }) it('should return false when decimal rounds to zero', () => { // 10.04 rounds to 10.0, so no decimal shown expect(shouldShowDecimal(10.04)).toBe(false) expect(shouldShowDecimal(10.049)).toBe(false) }) it('should return true when decimal rounds to non-zero', () => { // 10.05 rounds to 10.1, so decimal shown expect(shouldShowDecimal(10.05)).toBe(true) expect(shouldShowDecimal(10.06)).toBe(true) // 10.45 rounds to 10.5 expect(shouldShowDecimal(10.45)).toBe(true) }) }) describe('credit value formatting', () => { it('should show decimal for USD values that result in fractional credits', () => { // $0.05 * 211 = 10.55 credits → "10.6" const value1 = creditValue(0.05) expect(value1).toBe('10.6') // $0.10 * 211 = 21.1 credits → "21.1" const value2 = creditValue(0.1) expect(value2).toBe('21.1') }) it('should not show decimal for USD values that result in whole credits', () => { // $1.00 * 211 = 211 credits → "211" const value = creditValue(1.0) expect(value).toBe('211') }) it('should not show decimal when credits round to whole number', () => { // Find a USD value that results in credits close to a whole number // $0.0473933... * 211 ≈ 10.0 credits // Let's use a value that gives us ~10.02 credits which rounds to 10.0 const usd = 10.02 / CREDITS_PER_USD // ~0.0475 USD → ~10.02 credits const value = creditValue(usd) expect(value).toBe('10') }) }) describe('integration with pricing display', () => { it('should display decimal in badge for fractional credits', async () => { const { getNodeDisplayPrice } = useNodePricing() // $0.05 * 211 = 10.55 credits → "10.6 credits/Run" const node = createMockNodeWithPriceBadge( 'TestDecimalNode', priceBadge('{"type":"usd","usd":0.05}') ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe('10.6 credits/Run') }) it('should not display decimal in badge for whole credits', async () => { const { getNodeDisplayPrice } = useNodePricing() // $1.00 * 211 = 211 credits → "211 credits/Run" const node = createMockNodeWithPriceBadge( 'TestWholeCreditsNode', priceBadge('{"type":"usd","usd":1.00}') ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe('211 credits/Run') }) it('should handle range with mixed decimal display', async () => { const { getNodeDisplayPrice } = useNodePricing() // min: $0.05 * 211 = 10.55 → 10.6 // max: $1.00 * 211 = 211 → 211 const node = createMockNodeWithPriceBadge( 'TestMixedRangeNode', priceBadge('{"type":"range_usd","min_usd":0.05,"max_usd":1.00}') ) getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe('10.6-211 credits/Run') }) }) }) }) // ----------------------------------------------------------------------------- // formatPricingResult Tests // ----------------------------------------------------------------------------- describe('formatPricingResult', () => { describe('type: usd', () => { it('should format usd result', () => { const result = formatPricingResult({ type: 'usd', usd: 0.05 }) expect(result).toBe('10.6 credits/Run') }) it('should return valueOnly format', () => { const result = formatPricingResult( { type: 'usd', usd: 0.05 }, { valueOnly: true } ) expect(result).toBe('10.6') }) it('should handle approximate prefix in valueOnly mode', () => { const result = formatPricingResult( { type: 'usd', usd: 0.05, format: { approximate: true } }, { valueOnly: true } ) expect(result).toBe('~10.6') }) it('should return empty for null usd', () => { const result = formatPricingResult({ type: 'usd', usd: null as never }) expect(result).toBe('') }) }) describe('type: range_usd', () => { it('should format range result', () => { const result = formatPricingResult({ type: 'range_usd', min_usd: 0.05, max_usd: 0.1 }) expect(result).toBe('10.6-21.1 credits/Run') }) it('should return valueOnly format', () => { const result = formatPricingResult( { type: 'range_usd', min_usd: 0.05, max_usd: 0.1 }, { valueOnly: true } ) expect(result).toBe('10.6-21.1') }) it('should collapse range when min equals max', () => { const result = formatPricingResult( { type: 'range_usd', min_usd: 0.05, max_usd: 0.05 }, { valueOnly: true } ) expect(result).toBe('10.6') }) }) describe('type: list_usd', () => { it('should format list result', () => { const result = formatPricingResult({ type: 'list_usd', usd: [0.05, 0.1, 0.15] }) expect(result).toMatch(/\d+\.?\d*\/\d+\.?\d*\/\d+\.?\d* credits\/Run/) }) it('should return valueOnly format', () => { const result = formatPricingResult( { type: 'list_usd', usd: [0.05, 0.1] }, { valueOnly: true } ) expect(result).toBe('10.6/21.1') }) }) describe('type: text', () => { it('should return text as-is', () => { const result = formatPricingResult({ type: 'text', text: 'Free' }) expect(result).toBe('Free') }) }) describe('legacy format', () => { it('should handle {usd: number} without type field', () => { const result = formatPricingResult({ usd: 0.05 }) expect(result).toBe('10.6 credits/Run') }) it('should return valueOnly for legacy format', () => { const result = formatPricingResult({ usd: 0.05 }, { valueOnly: true }) expect(result).toBe('10.6') }) }) describe('invalid inputs', () => { it('should return empty for invalid type', () => { const result = formatPricingResult({ type: 'invalid' }) expect(result).toBe('') }) it('should return empty for null', () => { const result = formatPricingResult(null) expect(result).toBe('') }) it('should return empty for undefined', () => { const result = formatPricingResult(undefined) expect(result).toBe('') }) }) describe('non-finite numbers', () => { it('returns empty for type:usd when usd is a non-numeric string', () => { const result = formatPricingResult({ type: 'usd', usd: 'not-a-number' }) expect(result).toBe('') }) it('returns empty for type:usd when usd is Infinity', () => { const result = formatPricingResult({ type: 'usd', usd: Infinity }) expect(result).toBe('') }) it('returns empty for type:range_usd when min_usd or max_usd is NaN', () => { expect( formatPricingResult({ type: 'range_usd', min_usd: NaN, max_usd: 0.1 }) ).toBe('') expect( formatPricingResult({ type: 'range_usd', min_usd: 0.05, max_usd: NaN }) ).toBe('') }) it('returns empty for type:list_usd when usd is empty or all values are non-finite', () => { expect(formatPricingResult({ type: 'list_usd', usd: [] })).toBe('') expect( formatPricingResult({ type: 'list_usd', usd: [NaN, 'x', null] }) ).toBe('') }) it('drops non-finite entries from type:list_usd while keeping finite ones', () => { const result = formatPricingResult( { type: 'list_usd', usd: [0.05, NaN, 0.1] }, { valueOnly: true } ) expect(result).toBe('10.6/21.1') }) it('returns empty for legacy {usd} format when usd is non-finite', () => { expect(formatPricingResult({ usd: NaN })).toBe('') expect(formatPricingResult({ usd: 'abc' })).toBe('') }) }) }) // ----------------------------------------------------------------------------- // formatCreditsValue / Range / List Tests // ----------------------------------------------------------------------------- describe('formatCreditsValue', () => { it('should format USD to credits', () => { expect(formatCreditsValue(0.05)).toBe('10.6') expect(formatCreditsValue(1.0)).toBe('211') }) }) describe('formatCreditsRangeValue', () => { it('should format min-max range', () => { expect(formatCreditsRangeValue(0.05, 0.1)).toBe('10.6-21.1') }) it('should collapse when min equals max', () => { expect(formatCreditsRangeValue(0.05, 0.05)).toBe('10.6') }) }) describe('formatCreditsListValue', () => { it('should join values with separator', () => { expect(formatCreditsListValue([0.05, 0.1])).toBe('10.6/21.1') }) it('should use custom separator', () => { expect(formatCreditsListValue([0.05, 0.1], ' | ')).toBe('10.6 | 21.1') }) }) // ----------------------------------------------------------------------------- // evaluateNodeDefPricing Tests // ----------------------------------------------------------------------------- describe('evaluateNodeDefPricing', () => { const createMockNodeDef = ( overrides: Partial = {} ): ComfyNodeDef => ({ name: 'TestNode', display_name: 'Test Node', description: '', category: 'test', input: { required: {}, optional: {} }, output: [], output_name: [], output_is_list: [], python_module: 'test', ...overrides }) as ComfyNodeDef it('should return empty for node without price_badge', async () => { const nodeDef = createMockNodeDef() const result = await evaluateNodeDefPricing(nodeDef) expect(result).toBe('') }) it('should evaluate static expression', async () => { const nodeDef = createMockNodeDef({ name: 'StaticPriceNode', price_badge: { engine: 'jsonata', expr: '{"type":"usd","usd":0.05}', depends_on: { widgets: [], inputs: [], input_groups: [] } } }) const result = await evaluateNodeDefPricing(nodeDef) expect(result).toBe('10.6') }) it('should use default value from input spec', async () => { const nodeDef = createMockNodeDef({ name: 'DefaultValueNode', price_badge: { engine: 'jsonata', expr: '{"type":"usd","usd": widgets.count * 0.01}', depends_on: { widgets: [{ name: 'count', type: 'INT' }], inputs: [], input_groups: [] } }, input: { required: { count: ['INT', { default: 10 }] } } }) const result = await evaluateNodeDefPricing(nodeDef) expect(result).toBe('21.1') // 10 * 0.01 = 0.1 USD = 21.1 credits }) it('should use first option for COMBO without default', async () => { const nodeDef = createMockNodeDef({ name: 'ComboNode', price_badge: { engine: 'jsonata', expr: '(widgets.mode = "pro") ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}', depends_on: { widgets: [{ name: 'mode', type: 'COMBO' }], inputs: [], input_groups: [] } }, input: { required: { mode: [['standard', 'pro'], {}] } } }) const result = await evaluateNodeDefPricing(nodeDef) // First option is "standard", not "pro", so should be 0.05 USD expect(result).toBe('10.6') }) it('should use "original" as fallback for dynamic COMBO without input', async () => { const nodeDef = createMockNodeDef({ name: 'DynamicComboNode', price_badge: { engine: 'jsonata', expr: `( $prices := {"original": 0.05, "720p": 0.03}; {"type":"usd","usd": $lookup($prices, widgets.resolution)} )`, depends_on: { widgets: [{ name: 'resolution', type: 'COMBO' }], inputs: [], input_groups: [] } }, input: { required: { // resolution widget is NOT in inputs (dynamically created) } } }) const result = await evaluateNodeDefPricing(nodeDef) // Fallback to "original" = 0.05 USD expect(result).toBe('10.6') }) it('should handle dynamic combo with options array', async () => { const nodeDef = createMockNodeDef({ name: 'DynamicOptionsNode', price_badge: { engine: 'jsonata', expr: '{"type":"usd","usd": widgets.model = "model_a" ? 0.05 : 0.10}', depends_on: { widgets: [{ name: 'model', type: 'COMFY_DYNAMICCOMBO_V3' }], inputs: [], input_groups: [] } }, input: { required: { model: [ 'COMFY_DYNAMICCOMBO_V3', { options: [{ key: 'model_a' }, { key: 'model_b' }] } ] } } }) const result = await evaluateNodeDefPricing(nodeDef) // First option key is "model_a" = 0.05 USD expect(result).toBe('10.6') }) it('should assume inputs disconnected in preview', async () => { const nodeDef = createMockNodeDef({ name: 'InputConnectedNode', price_badge: { engine: 'jsonata', expr: 'inputs.image.connected ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}', depends_on: { widgets: [], inputs: ['image'], input_groups: [] } } }) const result = await evaluateNodeDefPricing(nodeDef) // In preview, inputs are assumed disconnected expect(result).toBe('10.6') }) it('should assume inputGroups have 0 count in preview', async () => { const nodeDef = createMockNodeDef({ name: 'InputGroupNode', price_badge: { engine: 'jsonata', expr: '{"type":"usd","usd": 0.05 + inputGroups.videos * 0.02}', depends_on: { widgets: [], inputs: [], input_groups: ['videos'] } } }) const result = await evaluateNodeDefPricing(nodeDef) // 0.05 + 0 * 0.02 = 0.05 USD expect(result).toBe('10.6') }) it('should return empty on JSONata error', async () => { const nodeDef = createMockNodeDef({ name: 'ErrorNode', price_badge: { engine: 'jsonata', expr: '$lookup(undefined, "key")', depends_on: { widgets: [], inputs: [], input_groups: [] } } }) const result = await evaluateNodeDefPricing(nodeDef) expect(result).toBe('') }) it('should handle range_usd result', async () => { const nodeDef = createMockNodeDef({ name: 'RangeNode', price_badge: { engine: 'jsonata', expr: '{"type":"range_usd","min_usd":0.05,"max_usd":0.10}', depends_on: { widgets: [], inputs: [], input_groups: [] } } }) const result = await evaluateNodeDefPricing(nodeDef) expect(result).toBe('10.6-21.1') }) it('should handle approximate format in valueOnly mode', async () => { const nodeDef = createMockNodeDef({ name: 'ApproximateNode', price_badge: { engine: 'jsonata', expr: '{"type":"usd","usd":0.05,"format":{"approximate":true}}', depends_on: { widgets: [], inputs: [], input_groups: [] } } }) const result = await evaluateNodeDefPricing(nodeDef) expect(result).toBe('~10.6') }) })