Files
ComfyUI_frontend/src/composables/node/useNodePricing.test.ts
Alexander Piskun 5c142275ad Move price badges to python nodes (#7816)
## Summary

Backend part: https://github.com/Comfy-Org/ComfyUI/pull/11582

- Move API node pricing definitions from hardcoded frontend functions to
backend-defined JSONata expressions
- Add `price_badge` field to node definition schema containing JSONata
expression and dependency declarations
- Implement async JSONata evaluation with signature-based caching for
efficient reactive updates
- Show one decimal in credit badges when meaningful (e.g., 1.5 credits
instead of 2 credits)

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7816-Move-price-badges-to-python-nodes-2da6d73d365081ec815ef61f7e3c65f7)
by [Unito](https://www.unito.io)
2026-01-22 09:17:07 -08:00

892 lines
28 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits'
import { useNodePricing } from '@/composables/node/useNodePricing'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { PriceBadge } from '@/schemas/nodeDefSchema'
// -----------------------------------------------------------------------------
// 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
}
interface MockNode {
id: string
widgets: MockNodeWidget[]
inputs: MockNodeInput[]
constructor: { nodeData: MockNodeData }
}
// -----------------------------------------------------------------------------
// 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 node: MockNode = {
id: Math.random().toString(),
widgets: mockWidgets,
inputs: mockInputs,
constructor: {
nodeData: {
name: nodeTypeName,
api_node: true,
price_badge: priceBadge
}
}
}
return node as unknown as LGraphNode
}
/** 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 }
})
// -----------------------------------------------------------------------------
// 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: MockNode = {
id: 'test',
widgets: [],
inputs: [],
constructor: {
nodeData: {
name: 'RegularNode',
api_node: false
}
}
}
const price = getNodeDisplayPrice(node as unknown as LGraphNode)
expect(price).toBe('')
})
it('should return empty string for nodes without price_badge', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node: MockNode = {
id: 'test',
widgets: [],
inputs: [],
constructor: {
nodeData: {
name: 'ApiNodeNoPricing',
api_node: true
}
}
}
const price = getNodeDisplayPrice(node as unknown as LGraphNode)
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: MockNode = {
id: 'test',
widgets: [],
inputs: [],
constructor: {
nodeData: {
name: 'NoPricingNode',
api_node: true
}
}
}
const config = getNodePricingConfig(node as unknown as LGraphNode)
expect(config).toBeUndefined()
})
it('should return undefined for non-API nodes', () => {
const { getNodePricingConfig } = useNodePricing()
const node: MockNode = {
id: 'test',
widgets: [],
inputs: [],
constructor: {
nodeData: {
name: 'RegularNode',
api_node: false
}
}
}
const config = getNodePricingConfig(node as unknown as LGraphNode)
expect(config).toBeUndefined()
})
})
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: MockNode = {
id: 'test',
widgets: [],
inputs: [],
constructor: {
nodeData: {
name: 'RegularNode',
api_node: false
}
}
}
expect(() =>
triggerPriceRecalculation(node as unknown as LGraphNode)
).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 return empty string for PricingResult missing type field', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
'TestMissingTypeNode',
// Returns object without type field
priceBadge('{"usd":0.05}')
)
getNodeDisplayPrice(node)
await new Promise((r) => setTimeout(r, 50))
const price = getNodeDisplayPrice(node)
expect(price).toBe('')
})
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: MockNode = {
id: Math.random().toString(),
widgets: [],
inputs: [
{ 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
],
constructor: {
nodeData: {
name: 'TestInputGroupNode',
api_node: true,
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": inputGroups.videos * 0.05}',
depends_on: {
widgets: [],
inputs: [],
input_groups: ['videos']
}
}
}
}
}
getNodeDisplayPrice(node as unknown as LGraphNode)
await new Promise((r) => setTimeout(r, 50))
const price = getNodeDisplayPrice(node as unknown as LGraphNode)
// 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')
})
})
})
})