mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 13:48:49 +00:00
1774 lines
56 KiB
TypeScript
1774 lines
56 KiB
TypeScript
import { createTestingPinia } from '@pinia/testing'
|
|
import { fromAny } from '@total-typescript/shoehorn'
|
|
import { setActivePinia } from 'pinia'
|
|
import { beforeEach, 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 { useNodeDefStore } from '@/stores/nodeDefStore'
|
|
import { toNodeId } from '@/types/nodeId'
|
|
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 }
|
|
})
|
|
}
|
|
|
|
async function resolveDisplayPrice(
|
|
node: LGraphNode,
|
|
widgetOverrides?: ReadonlyMap<string, unknown>
|
|
): Promise<string> {
|
|
const { getNodeDisplayPrice } = useNodePricing()
|
|
getNodeDisplayPrice(node, widgetOverrides)
|
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
return getNodeDisplayPrice(node, widgetOverrides)
|
|
}
|
|
|
|
function createStoredNodeDef(
|
|
name: string,
|
|
price_badge?: PriceBadge
|
|
): ComfyNodeDef {
|
|
return {
|
|
name,
|
|
display_name: name,
|
|
description: '',
|
|
category: 'test',
|
|
input: { required: {}, optional: {} },
|
|
output: [],
|
|
output_name: [],
|
|
output_is_list: [],
|
|
output_node: false,
|
|
python_module: 'test',
|
|
price_badge
|
|
} as ComfyNodeDef
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// 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 parse numeric strings and reject blank or invalid numbers', async () => {
|
|
const expression =
|
|
'{"type":"usd","usd": (widgets.count != null) ? widgets.count * 0.01 : 0.20}'
|
|
const badge = priceBadge(expression, [{ name: 'count', type: 'INT' }])
|
|
|
|
const parsedNode = createMockNodeWithPriceBadge(
|
|
'TestNumericStringNode',
|
|
badge,
|
|
[{ name: 'count', value: ' 5 ' }]
|
|
)
|
|
const blankNode = createMockNodeWithPriceBadge(
|
|
'TestBlankNumericStringNode',
|
|
badge,
|
|
[{ name: 'count', value: ' ' }]
|
|
)
|
|
const invalidNode = createMockNodeWithPriceBadge(
|
|
'TestInvalidNumericStringNode',
|
|
badge,
|
|
[{ name: 'count', value: 'five' }]
|
|
)
|
|
|
|
expect(await resolveDisplayPrice(parsedNode)).toBe(creditsLabel(0.05))
|
|
expect(await resolveDisplayPrice(blankNode)).toBe(creditsLabel(0.2))
|
|
expect(await resolveDisplayPrice(invalidNode)).toBe(creditsLabel(0.2))
|
|
})
|
|
|
|
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 preserve boolean combo values', async () => {
|
|
const node = createMockNodeWithPriceBadge(
|
|
'TestComboBooleanNode',
|
|
priceBadge(
|
|
'(widgets.enabled = false) ? {"type":"usd","usd":0.04} : {"type":"usd","usd":0.08}',
|
|
[{ name: 'enabled', type: 'COMBO' }]
|
|
),
|
|
[{ name: 'enabled', value: false }]
|
|
)
|
|
|
|
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.04))
|
|
})
|
|
|
|
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 parse BOOLEAN widget string values', async () => {
|
|
const badge = priceBadge(
|
|
'{"type":"usd","usd": widgets.premium ? 0.10 : 0.05}',
|
|
[{ name: 'premium', type: 'BOOLEAN' }]
|
|
)
|
|
const enabledNode = createMockNodeWithPriceBadge(
|
|
'TestBooleanStringTrueNode',
|
|
badge,
|
|
[{ name: 'premium', value: ' TRUE ' }]
|
|
)
|
|
const disabledNode = createMockNodeWithPriceBadge(
|
|
'TestBooleanStringFalseNode',
|
|
badge,
|
|
[{ name: 'premium', value: 'false' }]
|
|
)
|
|
|
|
expect(await resolveDisplayPrice(enabledNode)).toBe(creditsLabel(0.1))
|
|
expect(await resolveDisplayPrice(disabledNode)).toBe(creditsLabel(0.05))
|
|
})
|
|
|
|
it('should reject invalid BOOLEAN strings', async () => {
|
|
const node = createMockNodeWithPriceBadge(
|
|
'TestInvalidBooleanStringNode',
|
|
priceBadge(
|
|
'{"type":"usd","usd": widgets.premium = null ? 0.05 : 0.10}',
|
|
[{ name: 'premium', type: 'BOOLEAN' }]
|
|
),
|
|
[{ name: 'premium', value: 'sometimes' }]
|
|
)
|
|
|
|
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
|
|
})
|
|
|
|
it('should reject non-boolean values for BOOLEAN widgets', async () => {
|
|
const node = createMockNodeWithPriceBadge(
|
|
'TestInvalidBooleanNumberNode',
|
|
priceBadge(
|
|
'{"type":"usd","usd": widgets.premium = null ? 0.05 : 0.10}',
|
|
[{ name: 'premium', type: 'BOOLEAN' }]
|
|
),
|
|
[{ name: 'premium', value: 1 }]
|
|
)
|
|
|
|
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
|
|
})
|
|
|
|
it('should reject object values for numeric widgets', async () => {
|
|
const node = createMockNodeWithPriceBadge(
|
|
'TestObjectNumericNode',
|
|
priceBadge('{"type":"usd","usd": widgets.count = null ? 0.05 : 0.10}', [
|
|
{ name: 'count', type: 'INT' }
|
|
]),
|
|
[{ name: 'count', value: { count: 5 } }]
|
|
)
|
|
|
|
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
|
|
})
|
|
|
|
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('dependency context', () => {
|
|
it('should prefer widget overrides over node widget values', async () => {
|
|
const node = createMockNodeWithPriceBadge(
|
|
'TestWidgetOverrideNode',
|
|
priceBadge('{"type":"usd","usd": widgets.count * 0.01}', [
|
|
{ name: 'count', type: 'INT' }
|
|
]),
|
|
[{ name: 'count', value: 2 }]
|
|
)
|
|
|
|
const price = await resolveDisplayPrice(node, new Map([['count', '7']]))
|
|
|
|
expect(price).toBe(creditsLabel(0.07))
|
|
})
|
|
|
|
it('should treat missing input group arrays as zero connected inputs', async () => {
|
|
const node = Object.assign(createMockLGraphNode(), {
|
|
widgets: [],
|
|
constructor: {
|
|
nodeData: {
|
|
name: 'TestMissingInputGroupArrayNode',
|
|
api_node: true,
|
|
price_badge: priceBadge(
|
|
'{"type":"usd","usd": (inputGroups.images = 0) ? 0.05 : 0.10}',
|
|
[],
|
|
[],
|
|
['images']
|
|
)
|
|
}
|
|
}
|
|
})
|
|
|
|
expect(await resolveDisplayPrice(node)).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))
|
|
})
|
|
|
|
it('should default missing price badge engine and dependency arrays', async () => {
|
|
const bareBadge = {
|
|
expr: '{"type":"usd","usd":0.05}'
|
|
} as PriceBadge
|
|
const node = createMockNodeWithPriceBadge('TestBareBadgeNode', bareBadge)
|
|
|
|
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
|
|
|
|
const { getNodePricingConfig } = useNodePricing()
|
|
expect(getNodePricingConfig(node)).toMatchObject({
|
|
engine: 'jsonata',
|
|
depends_on: {
|
|
widgets: [],
|
|
inputs: [],
|
|
input_groups: []
|
|
}
|
|
})
|
|
})
|
|
|
|
it('should ignore non-jsonata pricing engines', () => {
|
|
const { getNodeDisplayPrice } = useNodePricing()
|
|
const node = createMockNodeWithPriceBadge(
|
|
'TestUnsupportedEngineNode',
|
|
fromAny<PriceBadge, unknown>({
|
|
engine: 'literal',
|
|
expr: '{"type":"usd","usd":0.05}',
|
|
depends_on: {
|
|
widgets: [],
|
|
inputs: [],
|
|
input_groups: []
|
|
}
|
|
})
|
|
)
|
|
|
|
expect(getNodeDisplayPrice(node)).toBe('')
|
|
})
|
|
})
|
|
|
|
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('node type pricing dependencies', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
})
|
|
|
|
it('returns empty dependency metadata for node types without pricing', () => {
|
|
const store = useNodeDefStore()
|
|
store.addNodeDef(createStoredNodeDef('UnpricedNode'))
|
|
const {
|
|
getInputGroupPrefixes,
|
|
getInputNames,
|
|
getRelevantWidgetNames,
|
|
hasDynamicPricing
|
|
} = useNodePricing()
|
|
|
|
expect(getRelevantWidgetNames('UnpricedNode')).toEqual([])
|
|
expect(hasDynamicPricing('UnpricedNode')).toBe(false)
|
|
expect(getInputGroupPrefixes('UnpricedNode')).toEqual([])
|
|
expect(getInputNames('UnpricedNode')).toEqual([])
|
|
})
|
|
|
|
it('dedupes dynamic pricing dependencies while preserving order', () => {
|
|
const store = useNodeDefStore()
|
|
store.addNodeDef(
|
|
createStoredNodeDef(
|
|
'DynamicPricingNode',
|
|
priceBadge(
|
|
'{"type":"usd","usd":0.05}',
|
|
[
|
|
{ name: 'seed', type: 'INT' },
|
|
{ name: 'quality', type: 'COMBO' }
|
|
],
|
|
['image', 'seed'],
|
|
['clips', 'image']
|
|
)
|
|
)
|
|
)
|
|
const {
|
|
getInputGroupPrefixes,
|
|
getInputNames,
|
|
getRelevantWidgetNames,
|
|
hasDynamicPricing
|
|
} = useNodePricing()
|
|
|
|
expect(getRelevantWidgetNames('DynamicPricingNode')).toEqual([
|
|
'seed',
|
|
'quality',
|
|
'image',
|
|
'clips'
|
|
])
|
|
expect(hasDynamicPricing('DynamicPricingNode')).toBe(true)
|
|
expect(getInputGroupPrefixes('DynamicPricingNode')).toEqual([
|
|
'clips',
|
|
'image'
|
|
])
|
|
expect(getInputNames('DynamicPricingNode')).toEqual(['image', 'seed'])
|
|
})
|
|
|
|
it('handles fixed pricing metadata without dependencies', () => {
|
|
const store = useNodeDefStore()
|
|
store.addNodeDef(
|
|
createStoredNodeDef(
|
|
'FixedPricingNode',
|
|
priceBadge('{"type":"usd","usd":0.05}')
|
|
)
|
|
)
|
|
const {
|
|
getInputGroupPrefixes,
|
|
getInputNames,
|
|
getRelevantWidgetNames,
|
|
hasDynamicPricing
|
|
} = useNodePricing()
|
|
|
|
expect(getRelevantWidgetNames('FixedPricingNode')).toEqual([])
|
|
expect(hasDynamicPricing('FixedPricingNode')).toBe(false)
|
|
expect(getInputGroupPrefixes('FixedPricingNode')).toEqual([])
|
|
expect(getInputNames('FixedPricingNode')).toEqual([])
|
|
})
|
|
|
|
it('handles price badges with omitted dependency metadata', () => {
|
|
const store = useNodeDefStore()
|
|
store.addNodeDef(
|
|
createStoredNodeDef('BareDependencyNode', {
|
|
engine: 'jsonata',
|
|
expr: '{"type":"usd","usd":0.05}'
|
|
} as PriceBadge)
|
|
)
|
|
const {
|
|
getInputGroupPrefixes,
|
|
getInputNames,
|
|
getRelevantWidgetNames,
|
|
hasDynamicPricing
|
|
} = useNodePricing()
|
|
|
|
expect(getRelevantWidgetNames('BareDependencyNode')).toEqual([])
|
|
expect(hasDynamicPricing('BareDependencyNode')).toBe(false)
|
|
expect(getInputGroupPrefixes('BareDependencyNode')).toEqual([])
|
|
expect(getInputNames('BareDependencyNode')).toEqual([])
|
|
})
|
|
})
|
|
|
|
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 nodeId = node.id
|
|
const revBefore = getNodeRevisionRef(nodeId).value
|
|
const tickBefore = pricingRevision.value
|
|
|
|
getNodeDisplayPrice(node)
|
|
await new Promise((r) => setTimeout(r, 50))
|
|
|
|
// VueNodes path bumps per-node ref and the global tick.
|
|
expect(getNodeRevisionRef(nodeId).value).toBeGreaterThan(revBefore)
|
|
expect(pricingRevision.value).toBeGreaterThan(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)
|
|
})
|
|
|
|
it('does not schedule duplicate work for the same in-flight signature', async () => {
|
|
const { getNodeDisplayPrice } = useNodePricing()
|
|
const node = createMockNodeWithPriceBadge(
|
|
'TestInFlightSignatureNode',
|
|
priceBadge('{"type":"usd","usd":0.05}')
|
|
)
|
|
|
|
expect(getNodeDisplayPrice(node)).toBe('')
|
|
expect(getNodeDisplayPrice(node)).toBe('')
|
|
await new Promise((r) => setTimeout(r, 50))
|
|
|
|
expect(getNodeDisplayPrice(node)).toBe(creditsLabel(0.05))
|
|
})
|
|
})
|
|
|
|
describe('getNodeRevisionRef', () => {
|
|
it('should return a ref for a node ID', () => {
|
|
const { getNodeRevisionRef } = useNodePricing()
|
|
const ref = getNodeRevisionRef(toNodeId('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(toNodeId('node-same'))
|
|
const ref2 = getNodeRevisionRef(toNodeId('node-same'))
|
|
|
|
expect(ref1).toBe(ref2)
|
|
})
|
|
|
|
it('should return different refs for different node IDs', () => {
|
|
const { getNodeRevisionRef } = useNodePricing()
|
|
const ref1 = getNodeRevisionRef(toNodeId('node-a'))
|
|
const ref2 = getNodeRevisionRef(toNodeId('node-b'))
|
|
|
|
expect(ref1).not.toBe(ref2)
|
|
})
|
|
|
|
it('should handle both string and number node IDs', () => {
|
|
const { getNodeRevisionRef } = useNodePricing()
|
|
const refFromNumber = getNodeRevisionRef(toNodeId(123))
|
|
const refFromString = getNodeRevisionRef(toNodeId('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 reuse the cached empty label after runtime failures', async () => {
|
|
const node = createMockNodeWithPriceBadge(
|
|
'TestCachedRuntimeErrorNode',
|
|
priceBadge('$lookup(undefined, "key")')
|
|
)
|
|
|
|
expect(await resolveDisplayPrice(node)).toBe('')
|
|
expect(await resolveDisplayPrice(node)).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 parse string usd values with default approximate formatting', () => {
|
|
const result = formatPricingResult(
|
|
{ type: 'usd', usd: '0.05' },
|
|
{ valueOnly: true, defaults: { approximate: true } }
|
|
)
|
|
expect(result).toBe('~10.6')
|
|
})
|
|
|
|
it('should return empty for null usd', () => {
|
|
const result = formatPricingResult({ type: 'usd', usd: null })
|
|
expect(result).toBe('')
|
|
})
|
|
|
|
it('should return empty for blank string usd', () => {
|
|
const result = formatPricingResult({ type: 'usd', usd: ' ' })
|
|
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')
|
|
})
|
|
|
|
it('should parse string range values with default approximate formatting', () => {
|
|
const result = formatPricingResult(
|
|
{ type: 'range_usd', min_usd: '0.05', max_usd: '0.1' },
|
|
{ valueOnly: true, defaults: { approximate: true } }
|
|
)
|
|
expect(result).toBe('~10.6-21.1')
|
|
})
|
|
})
|
|
|
|
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')
|
|
})
|
|
|
|
it('should return valueOnly format with approximate prefix', () => {
|
|
const result = formatPricingResult(
|
|
{ type: 'list_usd', usd: [0.05, 0.1] },
|
|
{ valueOnly: true, defaults: { approximate: true } }
|
|
)
|
|
expect(result).toBe('~10.6/21.1')
|
|
})
|
|
|
|
it('should return empty when list value is not an array', () => {
|
|
const result = formatPricingResult({
|
|
type: 'list_usd',
|
|
usd: 'not-a-list'
|
|
})
|
|
expect(result).toBe('')
|
|
})
|
|
})
|
|
|
|
describe('type: text', () => {
|
|
it('should return text as-is', () => {
|
|
const result = formatPricingResult({ type: 'text', text: 'Free' })
|
|
expect(result).toBe('Free')
|
|
})
|
|
|
|
it('should return empty when text is missing', () => {
|
|
const result = formatPricingResult({ type: 'text' })
|
|
expect(result).toBe('')
|
|
})
|
|
})
|
|
|
|
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> = {}
|
|
): 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 evaluate price badges with omitted dependency metadata', async () => {
|
|
const nodeDef = createMockNodeDef({
|
|
name: 'BareNodeDefPriceBadge',
|
|
price_badge: {
|
|
engine: 'jsonata',
|
|
expr: '{"type":"usd","usd":0.05}'
|
|
} as PriceBadge
|
|
})
|
|
|
|
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 default value from optional input spec', async () => {
|
|
const nodeDef = createMockNodeDef({
|
|
name: 'OptionalDefaultValueNode',
|
|
price_badge: {
|
|
engine: 'jsonata',
|
|
expr: '{"type":"usd","usd": widgets.count * 0.01}',
|
|
depends_on: {
|
|
widgets: [{ name: 'count', type: 'INT' }],
|
|
inputs: [],
|
|
input_groups: []
|
|
}
|
|
},
|
|
input: {
|
|
required: {},
|
|
optional: {
|
|
count: ['INT', { default: 4 }]
|
|
}
|
|
}
|
|
})
|
|
const result = await evaluateNodeDefPricing(nodeDef)
|
|
expect(result).toBe('8.4')
|
|
})
|
|
|
|
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 handle combo option arrays with primitive values', async () => {
|
|
const nodeDef = createMockNodeDef({
|
|
name: 'PrimitiveOptionsNode',
|
|
price_badge: {
|
|
engine: 'jsonata',
|
|
expr: '{"type":"usd","usd": widgets.mode = "fast" ? 0.05 : 0.10}',
|
|
depends_on: {
|
|
widgets: [{ name: 'mode', type: 'COMBO' }],
|
|
inputs: [],
|
|
input_groups: []
|
|
}
|
|
},
|
|
input: {
|
|
required: {
|
|
mode: ['COMBO', { options: ['fast', 'slow'] }]
|
|
}
|
|
}
|
|
})
|
|
|
|
const result = await evaluateNodeDefPricing(nodeDef)
|
|
|
|
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')
|
|
})
|
|
})
|