Compare commits

..

1 Commits

Author SHA1 Message Date
huang47
af733e9732 test: cover utility and base branch gaps 2026-07-01 00:24:02 -07:00
137 changed files with 1759 additions and 15575 deletions

View File

@@ -15,7 +15,7 @@ const { categories } = defineProps<{
const activeSection = ref(categories[0]?.value ?? '')
const HEADER_OFFSET_PX = -144
const HEADER_OFFSET = -144
const BOTTOM_THRESHOLD_PX = 4
const SCROLL_SAFETY_MS = 1500
@@ -52,7 +52,7 @@ function scrollToSection(id: string) {
const el = document.getElementById(id)
if (el) {
scrollTo(el, {
offset: HEADER_OFFSET_PX,
offset: HEADER_OFFSET,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: clearScrollLock

View File

@@ -1,5 +1,5 @@
<li
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow"
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow before:content-['']"
>
<slot />
</li>

View File

@@ -1,45 +0,0 @@
{
"last_node_id": 9,
"last_link_id": 9,
"nodes": [
{
"id": 9,
"type": "SaveImage",
"pos": {
"0": 64,
"1": 104
},
"size": {
"0": 210,
"1": 58
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": null
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"linearData": {
"inputs": [],
"outputs": ["9"]
}
},
"version": 0.4
}

View File

@@ -34,10 +34,6 @@ export class AppModeHelper {
public readonly outputPlaceholder: Locator
/** The linear-mode widget list container (visible in app mode). */
public readonly linearWidgets: Locator
/** The validation warning shown above the app mode run button. */
public readonly validationWarning: Locator
/** The action that opens graph mode errors from the validation warning. */
public readonly viewErrorsInGraphButton: Locator
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
public readonly imagePickerPopover: Locator
/** The Run button in the app mode footer. */
@@ -96,19 +92,13 @@ export class AppModeHelper {
this.outputPlaceholder = this.page.getByTestId(
TestIds.builder.outputPlaceholder
)
this.linearWidgets = this.page.getByTestId(TestIds.linear.widgetContainer)
this.validationWarning = this.page.getByTestId(
TestIds.linear.validationWarning
)
this.viewErrorsInGraphButton = this.validationWarning.getByTestId(
TestIds.linear.viewErrorsInGraph
)
this.linearWidgets = this.page.getByTestId('linear-widgets')
this.imagePickerPopover = this.page
.getByRole('dialog')
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
.first()
this.runButton = this.page
.getByTestId(TestIds.linear.runButton)
.getByTestId('linear-run-button')
.getByRole('button', { name: /run/i })
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
this.emptyWorkflowText = this.page.getByTestId(

View File

@@ -172,9 +172,6 @@ export const TestIds = {
mobileNavigation: 'linear-mobile-navigation',
mobileWorkflows: 'linear-mobile-workflows',
outputInfo: 'linear-output-info',
runButton: 'linear-run-button',
validationWarning: 'linear-validation-warning',
viewErrorsInGraph: 'linear-view-errors',
widgetContainer: 'linear-widgets'
},
builder: {

View File

@@ -1,106 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { enableErrorsOverlay } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { TestIds } from '@e2e/fixtures/selectors'
const SAVE_IMAGE_NODE_ID = '9'
function buildSaveImageRequiredInputError(): NodeError {
return {
class_type: 'SaveImage',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing: images',
details: '',
extra_info: { input_name: 'images' }
}
]
}
}
test.describe(
'App mode validation warning',
{ tag: ['@ui', '@workflow'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await enableErrorsOverlay(comfyPage)
await comfyPage.workflow.loadWorkflow('linear-validation-warning')
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
})
test('opens graph errors from the app mode validation warning', async ({
comfyPage
}) => {
await expect(comfyPage.appMode.validationWarning).toBeHidden()
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
})
await comfyPage.appMode.runButton.click()
const appModeOverlay = comfyPage.appMode.centerPanel.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(appModeOverlay).toBeHidden()
await expect(comfyPage.appMode.validationWarning).toBeVisible()
await expect(comfyPage.appMode.validationWarning).toContainText(
/Required input missing/i
)
await expect(comfyPage.appMode.viewErrorsInGraphButton).toBeVisible()
await comfyPage.appMode.viewErrorsInGraphButton.click()
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeVisible()
})
test('keeps the app mode run button enabled when the warning is visible', async ({
comfyPage
}) => {
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
})
await comfyPage.appMode.runButton.click()
await expect(comfyPage.appMode.validationWarning).toBeVisible()
await expect(comfyPage.appMode.runButton).toBeEnabled()
let promptQueued = false
const mockResponse: PromptResponse = {
prompt_id: 'test-id',
node_errors: {},
error: ''
}
await comfyPage.page.route(
'**/api/prompt',
async (route) => {
promptQueued = true
await route.fulfill({
status: 200,
body: JSON.stringify(mockResponse)
})
},
{ times: 1 }
)
await comfyPage.appMode.runButton.click()
await expect.poll(() => promptQueued).toBe(true)
})
}
)

View File

@@ -1,6 +1,5 @@
import { expect } from '@playwright/test'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
@@ -16,10 +15,9 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
await comfyPage.workflow.loadWorkflow('inputs/input_order_swap')
await expect
.poll(() =>
comfyPage.page.evaluate(
(linkId) => window.app!.graph!.links.get(linkId)?.target_slot,
toLinkId(1)
)
comfyPage.page.evaluate(() => {
return window.app!.graph!.links.get(1)?.target_slot
})
)
.toBe(1)
})

View File

@@ -3,7 +3,6 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Linear Mode', { tag: '@ui' }, () => {
test('Displays linear controls when app mode active', async ({
@@ -17,9 +16,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
test('Run button visible in linear mode', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(
comfyPage.page.getByTestId(TestIds.linear.runButton)
).toBeVisible()
await expect(comfyPage.page.getByTestId('linear-run-button')).toBeVisible()
})
test('Workflow info section visible', async ({ comfyPage }) => {

View File

@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('runWhenGlobalIdle', () => {
beforeEach(() => {
vi.resetModules()
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('falls back to a timeout when idle callbacks are unavailable', async () => {
vi.useFakeTimers()
vi.stubGlobal('requestIdleCallback', undefined)
vi.stubGlobal('cancelIdleCallback', undefined)
const { runWhenGlobalIdle } = await import('./async')
const runner = vi.fn()
const disposable = runWhenGlobalIdle(runner)
await vi.runAllTimersAsync()
expect(runner).toHaveBeenCalledOnce()
const deadline = runner.mock.calls[0][0]
expect(deadline.didTimeout).toBe(true)
expect(deadline.timeRemaining()).toBeGreaterThanOrEqual(0)
disposable.dispose()
disposable.dispose()
})
it('cancels fallback idle work before it runs', async () => {
vi.useFakeTimers()
vi.stubGlobal('requestIdleCallback', undefined)
vi.stubGlobal('cancelIdleCallback', undefined)
const { runWhenGlobalIdle } = await import('./async')
const runner = vi.fn()
runWhenGlobalIdle(runner).dispose()
await vi.runAllTimersAsync()
expect(runner).not.toHaveBeenCalled()
})
it('uses native idle callbacks when available', async () => {
const requestIdleCallback = vi.fn(() => 42)
const cancelIdleCallback = vi.fn()
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
vi.stubGlobal('cancelIdleCallback', cancelIdleCallback)
const { runWhenGlobalIdle } = await import('./async')
const runner = vi.fn()
const disposable = runWhenGlobalIdle(runner, 250)
expect(requestIdleCallback).toHaveBeenCalledWith(runner, { timeout: 250 })
disposable.dispose()
disposable.dispose()
expect(cancelIdleCallback).toHaveBeenCalledOnce()
expect(cancelIdleCallback).toHaveBeenCalledWith(42)
})
it('omits native idle timeout options when no timeout is supplied', async () => {
const requestIdleCallback = vi.fn(
(_cb: IdleRequestCallback, _options?: IdleRequestOptions) => 7
)
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
vi.stubGlobal('cancelIdleCallback', vi.fn())
const { runWhenGlobalIdle } = await import('./async')
const runner = vi.fn()
runWhenGlobalIdle(runner)
expect(requestIdleCallback).toHaveBeenCalledOnce()
expect(requestIdleCallback.mock.calls[0][0]).toBe(runner)
expect(requestIdleCallback.mock.calls[0][1]).toBeUndefined()
})
})

View File

@@ -4,6 +4,7 @@ import {
CREDITS_PER_USD,
COMFY_CREDIT_RATE_CENTS,
centsToCredits,
clampUsd,
creditsToCents,
creditsToUsd,
formatCredits,
@@ -43,4 +44,23 @@ describe('comfyCredits helpers', () => {
expect(formatCreditsFromUsd({ usd: 1, locale })).toBe('211.00')
expect(formatUsd({ value: 4.2, locale })).toBe('4.20')
})
test('recovers from incompatible fraction digit bounds', () => {
// {min:3,max:1} collapses to one fraction digit ('12.3'); the default {2,2}
// would yield '12.35', so this distinguishes recovery from options ignored.
expect(
formatCredits({
value: 12.345,
locale: 'en-US',
numberOptions: { minimumFractionDigits: 3, maximumFractionDigits: 1 }
})
).toBe('12.3')
})
test('clamps USD purchase values into the supported range', () => {
expect(clampUsd(Number.NaN)).toBe(0)
expect(clampUsd(-5)).toBe(1)
expect(clampUsd(42)).toBe(42)
expect(clampUsd(5000)).toBe(1000)
})
})

View File

@@ -37,7 +37,7 @@
size="unset"
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
data-testid="error-overlay-see-errors"
@click="viewErrorsInGraph"
@click="seeErrors"
>
{{
appMode
@@ -67,18 +67,31 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
const { appMode = false } = defineProps<{ appMode?: boolean }>()
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const { viewErrorsInGraph } = useViewErrorsInGraph()
const rightSidePanelStore = useRightSidePanelStore()
const canvasStore = useCanvasStore()
const { isVisible, overlayMessage, overlayTitle } = useErrorOverlayState()
function dismiss() {
executionErrorStore.dismissErrorOverlay()
}
function seeErrors() {
canvasStore.linearMode = false
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()
}
rightSidePanelStore.openPanel('errors')
executionErrorStore.dismissErrorOverlay()
}
</script>

View File

@@ -224,7 +224,7 @@ const handleOpenUserSettings = () => {
}
const handleOpenPlansAndPricing = () => {
subscriptionDialog.showPricingTable({ reason: 'avatar_menu_plans' })
subscriptionDialog.showPricingTable()
emit('close')
}
@@ -239,7 +239,8 @@ const handleOpenPlanAndCreditsSettings = () => {
}
const handleTopUp = () => {
useTelemetry()?.trackAddApiCreditButtonClicked({ source: 'avatar_menu' })
// Track purchase credits entry from avatar popover
useTelemetry()?.trackAddApiCreditButtonClicked()
dialogService.showTopUpCreditsDialog()
emit('close')
}
@@ -253,7 +254,7 @@ const handleOpenPartnerNodesInfo = () => {
}
const handleUpgradeToAddCredits = () => {
subscriptionDialog.showPricingTable({ reason: 'upgrade_to_add_credits' })
subscriptionDialog.showPricingTable()
emit('close')
}

View File

@@ -21,6 +21,6 @@ const { isFreeTier } = useBillingContext()
const subscriptionDialog = useSubscriptionDialog()
function handleClick() {
subscriptionDialog.showPricingTable({ reason: 'subscribe_now_button' })
subscriptionDialog.showPricingTable()
}
</script>

View File

@@ -1,6 +1,5 @@
import type { ComputedRef, Ref } from 'vue'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type {
BillingStatus,
@@ -76,10 +75,9 @@ export interface BillingActions {
*/
requireActiveSubscription: () => Promise<void>
/**
* Shows the subscription dialog. Pass a reason so the paywall open and any
* downstream checkout stay attributed to the triggering product moment.
* Shows the subscription dialog.
*/
showSubscriptionDialog: (options?: SubscriptionDialogOptions) => void
showSubscriptionDialog: () => void
}
export interface BillingState {

View File

@@ -7,7 +7,6 @@ import {
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
PreviewSubscribeOptions,
SubscribeOptions
@@ -282,8 +281,8 @@ function useBillingContextInternal(): BillingContext {
return activeContext.value.requireActiveSubscription()
}
function showSubscriptionDialog(options?: SubscriptionDialogOptions) {
return activeContext.value.showSubscriptionDialog(options)
function showSubscriptionDialog() {
return activeContext.value.showSubscriptionDialog()
}
return {

View File

@@ -2,7 +2,6 @@ import { computed, ref } from 'vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
BillingStatus,
BillingSubscriptionStatus,
@@ -190,12 +189,12 @@ export function useLegacyBilling(): BillingState & BillingActions {
async function requireActiveSubscription(): Promise<void> {
await fetchStatus()
if (!isActiveSubscription.value) {
legacyShowSubscriptionDialog({ reason: 'subscription_required' })
legacyShowSubscriptionDialog()
}
}
function showSubscriptionDialog(options?: SubscriptionDialogOptions): void {
legacyShowSubscriptionDialog(options)
function showSubscriptionDialog(): void {
legacyShowSubscriptionDialog()
}
return {

View File

@@ -5,7 +5,6 @@ import type { Ref, ShallowRef } from 'vue'
import { defineComponent, h, nextTick, ref, shallowRef } from 'vue'
import { useBoundingBoxes } from './useBoundingBoxes'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import type { BoundingBox } from '@/types/boundingBoxes'
import { toNodeId } from '@/types/nodeId'
@@ -36,26 +35,11 @@ const ctx = {
lineWidth: 0
} as unknown as CanvasRenderingContext2D
function makeCanvas(
options: {
context?: CanvasRenderingContext2D | null
clientWidth?: number
clientHeight?: number
} = {}
): HTMLCanvasElement {
function makeCanvas(): HTMLCanvasElement {
const el = document.createElement('canvas')
Object.defineProperty(el, 'clientWidth', {
value: options.clientWidth ?? 100,
configurable: true
})
Object.defineProperty(el, 'clientHeight', {
value: options.clientHeight ?? 100,
configurable: true
})
el.getContext = (() =>
options.context === undefined
? ctx
: options.context) as unknown as HTMLCanvasElement['getContext']
Object.defineProperty(el, 'clientWidth', { value: 100, configurable: true })
Object.defineProperty(el, 'clientHeight', { value: 100, configurable: true })
el.getContext = (() => ctx) as unknown as HTMLCanvasElement['getContext']
el.getBoundingClientRect = () =>
({
left: 0,
@@ -112,14 +96,14 @@ interface Captured extends Api {
modelValue: Ref<BoundingBox[]>
}
function setup(initial: BoundingBox[] | undefined = []) {
function setup(initial: BoundingBox[] = []) {
let captured: Captured | undefined
const Harness = defineComponent({
setup() {
const canvasEl = shallowRef<HTMLCanvasElement | null>(null)
const canvasContainer = shallowRef<HTMLDivElement | null>(null)
const inlineEditorEl = shallowRef<HTMLTextAreaElement | null>(null)
const modelValue = ref(initial as BoundingBox[])
const modelValue = ref(initial)
const api = useBoundingBoxes(toNodeId('1'), {
canvasEl,
canvasContainer,
@@ -175,43 +159,9 @@ describe('useBoundingBoxes initialization', () => {
expect(c.hasRegions.value).toBe(false)
expect(c.activeRegion.value).toBeNull()
})
it('falls back to default dimensions when the litegraph node is unavailable', () => {
appState.node = null
const c = setup([box()])
expect(c.canvasStyle.value).toEqual({ aspectRatio: '1024 / 1024' })
})
it('ignores non-positive dimension widgets', () => {
appState.node = {
widgets: [
{ name: 'width', value: 0 },
{ name: 'height', value: 'bad' }
],
findInputSlot: () => -1,
getInputNode: () => null
}
const c = setup()
expect(c.canvasStyle.value).toEqual({ aspectRatio: '1024 / 1024' })
})
it('treats an undefined model value as empty', () => {
const c = setup(undefined)
expect(c.hasRegions.value).toBe(false)
expect(c.modelValue.value).toEqual([])
})
})
describe('useBoundingBoxes drawing', () => {
it('ignores non-primary pointer buttons', async () => {
const c = setup()
c.onPointerDown(pe(10, 10, { button: 1 }))
c.onCanvasPointerMove(pe(60, 60))
c.onDocPointerUp(pe(60, 60))
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
it('draws a new region and syncs it to the model value', async () => {
const c = setup()
c.onPointerDown(pe(10, 10))
@@ -237,102 +187,6 @@ describe('useBoundingBoxes drawing', () => {
await flush()
expect(c.modelValue.value).toHaveLength(1)
})
it('moves an existing active region by dragging inside it', async () => {
const c = setup([box()])
c.onPointerDown(pe(30, 30))
c.onCanvasPointerMove(pe(45, 50))
c.onDocPointerUp(pe(45, 50))
await flush()
expect(c.modelValue.value[0].x).toBeGreaterThan(51)
expect(c.modelValue.value[0].y).toBeGreaterThan(51)
})
it('resizes an existing active region from its corner handle', async () => {
const c = setup([box()])
c.onPointerDown(pe(60, 60))
c.onCanvasPointerMove(pe(80, 80))
c.onDocPointerUp(pe(80, 80))
await flush()
expect(c.modelValue.value[0].width).toBeGreaterThan(256)
expect(c.modelValue.value[0].height).toBeGreaterThan(256)
})
it('keeps selection valid when Alt-clicking overlapping regions', async () => {
const c = setup([
box(),
box({
metadata: {
type: 'obj',
text: '',
desc: 'second',
palette: ['#ff0000']
}
})
])
c.onPointerDown(pe(30, 30, { altKey: true }))
c.onDocPointerUp(pe(30, 30))
await flush()
expect(c.activeRegion.value).not.toBeNull()
expect(c.modelValue.value).toHaveLength(2)
})
it('ignores document movement and pointer up when no draw is active', async () => {
const c = setup([box()])
c.onCanvasPointerMove(pe(5, 95))
c.onDocPointerUp(pe(95, 95))
await flush()
expect(c.modelValue.value).toHaveLength(1)
})
it('uses zero pointer coordinates when the canvas is unavailable', async () => {
const c = setup()
c.canvasEl.value = null
c.onPointerDown(pe(50, 50))
c.onCanvasPointerMove(pe(80, 80))
c.onDocPointerUp(pe(80, 80))
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
it('redraws active text regions with fallback palette color', async () => {
const c = setup([
box({
x: 10,
y: 10,
width: 30,
height: 30,
metadata: {
type: 'text',
text: 'hello',
desc: 'alpha beta\n\ncharlie',
palette: []
}
})
])
c.focused.value = true
c.syncState()
await flush()
expect(c.modelValue.value).toHaveLength(1)
})
it('draws safely when the canvas context is unavailable', async () => {
const c = setup([box()])
c.canvasEl.value = makeCanvas({ context: null })
c.syncState()
await flush()
expect(c.modelValue.value).toHaveLength(1)
})
})
describe('useBoundingBoxes region editing', () => {
@@ -360,60 +214,6 @@ describe('useBoundingBoxes region editing', () => {
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
it('does nothing when changing type without an active region', async () => {
const c = setup()
c.setActiveType('text')
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
it('deletes the active region on Backspace', async () => {
const c = setup([box()])
c.onCanvasKeyDown({
key: 'Backspace',
preventDefault: () => {},
stopPropagation: () => {}
} as unknown as KeyboardEvent)
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
it('ignores unrelated keys and key events while drawing', async () => {
const c = setup([box()])
c.onCanvasKeyDown({
key: 'Enter',
preventDefault: () => {
throw new Error('should not prevent')
},
stopPropagation: () => {}
} as unknown as KeyboardEvent)
c.onPointerDown(pe(80, 80))
c.onCanvasKeyDown({
key: 'Delete',
preventDefault: () => {
throw new Error('should not prevent while drawing')
},
stopPropagation: () => {}
} as unknown as KeyboardEvent)
c.onDocPointerUp(pe(80, 80))
await flush()
expect(c.modelValue.value).toHaveLength(1)
})
it('keeps a remaining region selected after deleting from a multi-region list', async () => {
const c = setup([box(), box({ x: 10 })])
c.onCanvasKeyDown({
key: 'Delete',
preventDefault: () => {},
stopPropagation: () => {}
} as unknown as KeyboardEvent)
await flush()
expect(c.modelValue.value).toHaveLength(1)
expect(c.activeRegion.value).not.toBeNull()
})
})
describe('useBoundingBoxes inline editor', () => {
@@ -437,86 +237,6 @@ describe('useBoundingBoxes inline editor', () => {
c.onInlineKeyDown({ key: 'Escape' } as KeyboardEvent)
expect(c.inlineEditor.value).toBeNull()
})
it('commits the inline editor on Ctrl+Enter', async () => {
const c = setup([box()])
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
await flush()
c.inlineEditor.value!.value = 'committed'
c.onInlineKeyDown({
key: 'Enter',
ctrlKey: true,
metaKey: false
} as KeyboardEvent)
await flush()
expect(c.modelValue.value[0].metadata.desc).toBe('committed')
})
it('commits the inline editor on Meta+Enter', async () => {
const c = setup([box()])
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
await flush()
c.inlineEditor.value!.value = 'meta committed'
c.onInlineKeyDown({
key: 'Enter',
ctrlKey: false,
metaKey: true
} as KeyboardEvent)
await flush()
expect(c.modelValue.value[0].metadata.desc).toBe('meta committed')
})
it('ignores Enter without a modifier in the inline editor', async () => {
const c = setup([box()])
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
await flush()
c.inlineEditor.value!.value = 'not committed'
c.onInlineKeyDown({
key: 'Enter',
ctrlKey: false,
metaKey: false
} as KeyboardEvent)
await flush()
expect(c.modelValue.value[0].metadata.desc).toBe('')
})
it('leaves state unchanged when committing without an editor', async () => {
const c = setup([box()])
c.commitInlineEditor()
await flush()
expect(c.modelValue.value[0].metadata.desc).toBe('')
})
it('closes a stale inline editor after its region was removed', async () => {
const c = setup([box()])
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
await flush()
c.inlineEditor.value!.value = 'stale'
c.clearAll()
c.commitInlineEditor()
await flush()
expect(c.inlineEditor.value).toBeNull()
expect(c.modelValue.value).toHaveLength(0)
})
it('does not open the inline editor when double-clicking empty space', async () => {
const c = setup([box({ x: 0, y: 0, width: 50, height: 50 })])
c.onDoubleClick(pe(95, 95) as unknown as MouseEvent)
await flush()
expect(c.inlineEditor.value).toBeNull()
})
it('uses zero mouse coordinates when double-clicking without a canvas', async () => {
const c = setup([box({ x: 0, y: 0, width: 512, height: 512 })])
c.canvasEl.value = null
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
await flush()
expect(c.inlineEditor.value).not.toBeNull()
})
})
describe('useBoundingBoxes hover cursor', () => {
@@ -527,74 +247,4 @@ describe('useBoundingBoxes hover cursor', () => {
await flush()
expect(c.canvasCursor.value).toBe('pointer')
})
it('returns to the default cursor after leaving the canvas', async () => {
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])
c.onCanvasPointerMove(pe(15, 15))
await flush()
c.onPointerLeave()
await flush()
expect(c.canvasCursor.value).toBe('crosshair')
})
it('does nothing when leaving without hover state', async () => {
const c = setup([box()])
c.onPointerLeave()
await flush()
expect(c.canvasCursor.value).toBe('crosshair')
})
it('keeps cursor default when canvas context is unavailable for title hit testing', async () => {
const c = setup([box()])
c.canvasEl.value = makeCanvas({ context: null })
c.onCanvasPointerMove(pe(30, 30))
await flush()
expect(c.canvasCursor.value).toBe('crosshair')
})
it('keeps hover state unchanged when pointer movement hits the same tag', async () => {
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])
c.onCanvasPointerMove(pe(15, 15))
await flush()
c.onCanvasPointerMove(pe(15, 15))
await flush()
expect(c.canvasCursor.value).toBe('pointer')
})
})
describe('useBoundingBoxes background image', () => {
it('loads a background image and snaps node dimensions', async () => {
const widthCallback = vi.fn()
const heightCallback = vi.fn()
const inputNode = { id: 2 }
appState.node = {
widgets: [
{ name: 'width', value: 512, callback: widthCallback },
{ name: 'height', value: 512, callback: heightCallback }
],
findInputSlot: () => 0,
getInputNode: () => inputNode
}
const store = useNodeOutputStore()
vi.spyOn(store, 'getNodeImageUrls').mockReturnValue(['blob:bg'])
class FakeImage {
crossOrigin = ''
naturalWidth = 257
naturalHeight = 271
onload: (() => void) | null = null
set src(_value: string) {
this.onload?.()
}
}
vi.stubGlobal('Image', FakeImage)
setup([box()])
await flush()
expect(widthCallback).toHaveBeenCalledWith(256)
expect(heightCallback).toHaveBeenCalledWith(272)
})
})

View File

@@ -1,118 +0,0 @@
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
type Graph = {
isRootGraph: boolean
}
type FocusableNode = {
graph?: Graph
boundingRect: DOMRect
}
const { appState, canvasStore, getNodeByExecutionId } = vi.hoisted(() => ({
appState: {
rootGraph: { isRootGraph: true }
},
canvasStore: {
canvas: undefined as
| undefined
| {
graph: Graph
subgraph?: Graph
setGraph: ReturnType<typeof vi.fn>
animateToBounds: ReturnType<typeof vi.fn>
}
},
getNodeByExecutionId: vi.fn()
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => canvasStore
}))
vi.mock('@/scripts/app', () => ({
app: appState
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId
}))
beforeEach(() => {
getNodeByExecutionId.mockReset()
vi.stubGlobal(
'requestAnimationFrame',
(callback: FrameRequestCallback): number => {
callback(0)
return 1
}
)
canvasStore.canvas = {
graph: appState.rootGraph,
setGraph: vi.fn(),
animateToBounds: vi.fn()
}
})
describe('useFocusNode', () => {
it('does nothing when there is no canvas or matching graph node', async () => {
canvasStore.canvas = undefined
await useFocusNode().focusNode('node-1')
expect(getNodeByExecutionId).not.toHaveBeenCalled()
canvasStore.canvas = {
graph: appState.rootGraph,
setGraph: vi.fn(),
animateToBounds: vi.fn()
}
getNodeByExecutionId.mockReturnValue({ boundingRect: new DOMRect() })
await useFocusNode().focusNode('node-1')
expect(canvasStore.canvas.animateToBounds).not.toHaveBeenCalled()
})
it('navigates to the node graph before focusing its bounds', async () => {
const subgraph = { isRootGraph: false }
const bounds = new DOMRect(1, 2, 3, 4)
getNodeByExecutionId.mockReturnValue({
graph: subgraph,
boundingRect: bounds
} satisfies FocusableNode)
await useFocusNode().focusNode('node-1')
expect(getNodeByExecutionId).toHaveBeenCalledWith(
appState.rootGraph,
'node-1'
)
expect(canvasStore.canvas?.subgraph).toBe(subgraph)
expect(canvasStore.canvas?.setGraph).toHaveBeenCalledWith(subgraph)
expect(canvasStore.canvas?.animateToBounds).toHaveBeenCalledWith(bounds)
})
it('uses an execution id map and skips graph navigation when already there', async () => {
const graph = { isRootGraph: true }
const bounds = new DOMRect(5, 6, 7, 8)
canvasStore.canvas = {
graph,
setGraph: vi.fn(),
animateToBounds: vi.fn()
}
const node = { graph, boundingRect: bounds } satisfies FocusableNode
await useFocusNode().focusNode(
'node-1',
new Map([['node-1', fromAny<LGraphNode, unknown>(node)]])
)
expect(getNodeByExecutionId).not.toHaveBeenCalled()
expect(canvasStore.canvas.setGraph).not.toHaveBeenCalled()
expect(canvasStore.canvas.animateToBounds).toHaveBeenCalledWith(bounds)
})
})

View File

@@ -1,6 +1,6 @@
import { render } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, h, markRaw, nextTick, ref } from 'vue'
import { defineComponent, h, markRaw, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionToolboxPosition'
@@ -12,35 +12,19 @@ import {
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { toNodeId } from '@/types/nodeId'
import { createMockPositionable } from '@/utils/__tests__/litegraphTestUtils'
const mockApp = vi.hoisted(() => ({
canvas: null
}))
const mockFeatureFlags = vi.hoisted(() => ({
refs: null as null | {
shouldRenderVueNodes: { value: boolean }
}
}))
vi.mock('@/scripts/app', () => ({ app: mockApp }))
vi.mock('@/composables/useVueFeatureFlags', async () => {
const { ref } = await import('vue')
const shouldRenderVueNodes = ref(false)
mockFeatureFlags.refs = {
shouldRenderVueNodes
}
return {
useVueFeatureFlags: () => ({
shouldRenderVueNodes
})
}
})
vi.mock('@/composables/useVueFeatureFlags', () => ({
useVueFeatureFlags: () => ({
shouldRenderVueNodes: { value: false }
})
}))
describe('useSelectionToolboxPosition', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
@@ -48,39 +32,28 @@ describe('useSelectionToolboxPosition', () => {
beforeEach(() => {
setActivePinia(createPinia())
canvasStore = useCanvasStore()
layoutStore.initializeFromLiteGraph([])
layoutStore.isDraggingVueNodes.value = false
if (mockFeatureFlags.refs) {
mockFeatureFlags.refs.shouldRenderVueNodes.value = false
}
})
function renderToolboxForSelection(
items: Iterable<Positionable>,
state: Partial<LGraphCanvas['state']> = {},
ds: Partial<LGraphCanvas['ds']> = {}
) {
function renderToolboxForSelection(item: Positionable) {
canvasStore.canvas = markRaw({
canvas: document.createElement('canvas'),
ds: {
offset: ds.offset ?? [0, 0],
scale: ds.scale ?? 1
offset: [0, 0],
scale: 1
},
selectedItems: new Set(items),
selectedItems: new Set([item]),
state: {
draggingItems: false,
selectionChanged: true,
...state
selectionChanged: true
}
} as Partial<LGraphCanvas> as LGraphCanvas)
let toolbox: HTMLElement | undefined
let visible!: ReturnType<typeof useSelectionToolboxPosition>['visible']
const TestHarness = defineComponent({
setup() {
const toolboxRef = ref<HTMLElement>(document.createElement('div'))
toolbox = toolboxRef.value
;({ visible } = useSelectionToolboxPosition(toolboxRef))
useSelectionToolboxPosition(toolboxRef)
return () => h('div')
}
})
@@ -88,28 +61,7 @@ describe('useSelectionToolboxPosition', () => {
const wrapper = render(TestHarness)
if (!toolbox) throw new Error('Toolbox element was not initialized')
if (!visible) throw new Error('Visible state was not initialized')
return { toolbox, unmount: wrapper.unmount, visible }
}
function setCanvasSelection(
items: Iterable<Positionable>,
state: Partial<LGraphCanvas['state']> = {}
) {
canvasStore.canvas = markRaw({
canvas: document.createElement('canvas'),
ds: {
offset: [0, 0],
scale: 1
},
selectedItems: new Set(items),
state: {
draggingItems: false,
selectionChanged: true,
...state
}
} as Partial<LGraphCanvas> as LGraphCanvas)
return { toolbox, unmount: wrapper.unmount }
}
it('positions groups from their unchanged bounds', () => {
@@ -117,7 +69,7 @@ describe('useSelectionToolboxPosition', () => {
group.pos = [100, 200]
group.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection([group])
const { toolbox, unmount } = renderToolboxForSelection(group)
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px')
unmount()
@@ -129,221 +81,11 @@ describe('useSelectionToolboxPosition', () => {
node.pos = [100, 200]
node.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection([node])
const { toolbox, unmount } = renderToolboxForSelection(node)
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
`${190 - LiteGraph.NODE_TITLE_HEIGHT}px`
)
unmount()
})
it('does not set coordinates when selection is empty', () => {
const { toolbox, unmount } = renderToolboxForSelection([])
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
unmount()
})
it('does not update when selection state is unchanged', () => {
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [160, 80]
const { toolbox, visible, unmount } = renderToolboxForSelection([group], {
selectionChanged: false
})
expect(visible.value).toBe(false)
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
unmount()
})
it('does not set coordinates while selected items are being dragged', () => {
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection([group], {
draggingItems: true
})
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
unmount()
})
it('positions multiple selected items from their union bounds', () => {
const first = new LGraphGroup('First', 1)
first.pos = [100, 200]
first.size = [100, 40]
const second = new LGraphGroup('Second', 2)
second.pos = [300, 260]
second.size = [50, 40]
const { toolbox, unmount } = renderToolboxForSelection([first, second])
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('270px')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px')
unmount()
})
it('applies canvas scale and offset to screen coordinates', () => {
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [100, 40]
const { toolbox, unmount } = renderToolboxForSelection(
[group],
{},
{ offset: [10, 20], scale: 2 }
)
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('360px')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('420px')
unmount()
})
it('uses Vue layout bounds when Vue node rendering is enabled', () => {
if (!mockFeatureFlags.refs) {
throw new Error('feature flag refs were not initialized')
}
mockFeatureFlags.refs.shouldRenderVueNodes.value = true
const node = new LGraphNode('Node')
node.id = toNodeId(12)
node.pos = [100, 200]
node.size = [160, 80]
layoutStore.initializeFromLiteGraph([
{
id: node.id,
pos: [300, 400],
size: [200, 120]
}
])
const { toolbox, unmount } = renderToolboxForSelection([node])
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('400px')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
`${390 - LiteGraph.NODE_TITLE_HEIGHT}px`
)
unmount()
})
it('falls back to LiteGraph node bounds when Vue layout is missing', () => {
if (!mockFeatureFlags.refs) {
throw new Error('feature flag refs were not initialized')
}
mockFeatureFlags.refs.shouldRenderVueNodes.value = true
const node = new LGraphNode('Node')
node.id = toNodeId(13)
node.pos = [100, 200]
node.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection([node])
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
`${190 - LiteGraph.NODE_TITLE_HEIGHT}px`
)
unmount()
})
it('hides the toolbox while Vue nodes are being dragged', () => {
if (!mockFeatureFlags.refs) {
throw new Error('feature flag refs were not initialized')
}
mockFeatureFlags.refs.shouldRenderVueNodes.value = true
layoutStore.isDraggingVueNodes.value = true
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection([group])
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
unmount()
})
it('ignores selected items that are not nodes or groups', () => {
const item = createMockPositionable({
id: toNodeId(52),
pos: [100, 200],
size: [160, 80],
boundingRect: [100, 200, 160, 80]
})
const { toolbox, visible, unmount } = renderToolboxForSelection([item])
expect(visible.value).toBe(true)
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
unmount()
})
it('ignores selected items without valid ids', () => {
const item = {
id: null,
pos: [100, 200],
size: [160, 80],
boundingRect: [100, 200, 160, 80]
} as unknown as Positionable
const { toolbox, visible, unmount } = renderToolboxForSelection([item])
expect(visible.value).toBe(true)
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
unmount()
})
it('stays visible without mutating style when the toolbox ref is empty', () => {
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [160, 80]
setCanvasSelection([group])
let visible!: ReturnType<typeof useSelectionToolboxPosition>['visible']
const TestHarness = defineComponent({
setup() {
;({ visible } = useSelectionToolboxPosition(ref()))
return () => h('div')
}
})
const wrapper = render(TestHarness)
expect(visible.value).toBe(true)
wrapper.unmount()
})
it('hides and restores around Vue node drag state changes', async () => {
if (!mockFeatureFlags.refs) {
throw new Error('feature flag refs were not initialized')
}
mockFeatureFlags.refs.shouldRenderVueNodes.value = true
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [160, 80]
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(0), 0)
)
vi.stubGlobal('cancelAnimationFrame', (handle: number) => {
clearTimeout(handle)
})
const { visible, unmount } = renderToolboxForSelection([group])
expect(visible.value).toBe(true)
layoutStore.isDraggingVueNodes.value = true
await nextTick()
expect(visible.value).toBe(false)
layoutStore.isDraggingVueNodes.value = false
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(visible.value).toBe(true)
unmount()
})
})

View File

@@ -1,100 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
canvas: {
canvas: {},
ds: {
scale: 3
}
},
canvasPosToClientPos: vi.fn((pos: [number, number]) => [
pos[0] + 10,
pos[1] + 20
]),
getCanvas: vi.fn(),
getSetting: vi.fn(),
updateCanvasPosition: vi.fn()
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
getCanvas: mocks.getCanvas
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: mocks.getSetting
})
}))
vi.mock('@/composables/element/useCanvasPositionConversion', () => ({
useCanvasPositionConversion: vi.fn(() => ({
canvasPosToClientPos: mocks.canvasPosToClientPos,
update: mocks.updateCanvasPosition
}))
}))
const { useAbsolutePosition } = await import('./useAbsolutePosition')
describe('useAbsolutePosition', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getCanvas.mockReturnValue(mocks.canvas)
mocks.canvas.ds.scale = 3
})
it('positions and scales an element with the canvas scale', () => {
const { style, updatePosition } = useAbsolutePosition()
updatePosition({
pos: [1, 2],
size: [4, 5]
})
expect(style.value).toMatchObject({
position: 'fixed',
left: '11px',
top: '22px',
width: '12px',
height: '15px'
})
})
it('uses an explicit scale when provided', () => {
const { style, updatePosition } = useAbsolutePosition()
updatePosition({
pos: [1, 2],
size: [4, 5],
scale: 2
})
expect(style.value).toMatchObject({
width: '8px',
height: '10px'
})
})
it('applies transform scaling without resizing the element bounds', () => {
const { style, updatePosition } = useAbsolutePosition({
useTransform: true
})
updatePosition({
pos: [1, 2],
size: [4, 5],
scale: 2
})
expect(style.value).toMatchObject({
position: 'fixed',
transformOrigin: '0 0',
transform: 'scale(2)',
left: '11px',
top: '22px',
width: '4px',
height: '5px'
})
})
})

View File

@@ -1,86 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
const mocks = vi.hoisted(() => {
const canvas = {
canvas: {},
ds: {
offset: [10, 20],
scale: 2
}
} as unknown as LGraphCanvas
return {
bounds: {
left: { value: 4 },
top: { value: 6 }
},
canvas,
getCanvas: vi.fn(() => canvas),
update: vi.fn()
}
})
vi.mock('@vueuse/core', () => ({
useElementBounding: vi.fn(() => ({
left: mocks.bounds.left,
top: mocks.bounds.top,
update: mocks.update
}))
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
getCanvas: mocks.getCanvas
})
}))
const { useCanvasPositionConversion, useSharedCanvasPositionConversion } =
await import('./useCanvasPositionConversion')
describe('useCanvasPositionConversion', () => {
beforeEach(() => {
mocks.bounds.left.value = 4
mocks.bounds.top.value = 6
mocks.getCanvas.mockClear()
mocks.update.mockClear()
})
it('converts client positions into canvas coordinates', () => {
const { clientPosToCanvasPos } = useCanvasPositionConversion(
mocks.canvas.canvas,
mocks.canvas
)
expect(clientPosToCanvasPos([34, 66])).toEqual([5, 10])
})
it('converts canvas positions into client coordinates', () => {
const { canvasPosToClientPos } = useCanvasPositionConversion(
mocks.canvas.canvas,
mocks.canvas
)
expect(canvasPosToClientPos([5, 10])).toEqual([34, 66])
})
it('returns the element bounds update callback', () => {
const { update } = useCanvasPositionConversion(
mocks.canvas.canvas,
mocks.canvas
)
update()
expect(mocks.update).toHaveBeenCalledTimes(1)
})
it('reuses the shared converter instance', () => {
const first = useSharedCanvasPositionConversion()
const second = useSharedCanvasPositionConversion()
expect(second).toBe(first)
expect(mocks.getCanvas).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,82 +0,0 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useMutationObserver, useResizeObserver } from '@vueuse/core'
import { useOverflowObserver } from './useOverflowObserver'
vi.mock('@vueuse/core', () => ({
useMutationObserver: vi.fn(() => ({ stop: vi.fn() })),
useResizeObserver: vi.fn(() => ({ stop: vi.fn() }))
}))
const useMutationObserverMock = vi.mocked(useMutationObserver)
const useResizeObserverMock = vi.mocked(useResizeObserver)
function setElementWidths(
element: HTMLElement,
widths: { scrollWidth: number; clientWidth: number }
) {
Object.defineProperty(element, 'scrollWidth', {
value: widths.scrollWidth,
configurable: true
})
Object.defineProperty(element, 'clientWidth', {
value: widths.clientWidth,
configurable: true
})
}
describe('useOverflowObserver', () => {
beforeEach(() => {
vi.clearAllMocks()
useMutationObserverMock.mockReturnValue(fromPartial({ stop: vi.fn() }))
useResizeObserverMock.mockReturnValue(fromPartial({ stop: vi.fn() }))
})
it('checks overflow immediately when debounce is disabled', () => {
const element = document.createElement('div')
const onCheck = vi.fn()
setElementWidths(element, { scrollWidth: 120, clientWidth: 100 })
const observer = useOverflowObserver(element, {
debounceTime: 0,
onCheck
})
observer.checkOverflow()
expect(observer.isOverflowing.value).toBe(true)
expect(onCheck).toHaveBeenCalledWith(true)
})
it('can skip observers and still dispose', () => {
const element = document.createElement('div')
const observer = useOverflowObserver(element, {
useMutationObserver: false,
useResizeObserver: false
})
observer.dispose()
expect(observer.disposed.value).toBe(true)
expect(useMutationObserverMock).not.toHaveBeenCalled()
expect(useResizeObserverMock).not.toHaveBeenCalled()
})
it('stops enabled observers on dispose', () => {
const element = document.createElement('div')
const stopMutation = vi.fn()
const stopResize = vi.fn()
useMutationObserverMock.mockReturnValue(fromPartial({ stop: stopMutation }))
useResizeObserverMock.mockReturnValue(fromPartial({ stop: stopResize }))
const observer = useOverflowObserver(element)
observer.dispose()
expect(stopMutation).toHaveBeenCalledOnce()
expect(stopResize).toHaveBeenCalledOnce()
})
})

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from 'vitest'
import { describe, it, expect } from 'vitest'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { MenuOption } from './useMoreOptionsMenu'
import {
@@ -360,203 +360,5 @@ describe('contextMenuConverter', () => {
)
expect(hasExtensionsCategory).toBe(true)
})
it('skips items without content and duplicate equivalents', () => {
const result = convertContextMenuToOptions(
[
{ content: '', callback: () => {} },
{ content: 'Duplicate', callback: () => {} },
{ content: 'Clone', callback: () => {} }
],
undefined,
false
)
expect(result.map((option) => option.label)).toEqual(['Duplicate'])
})
it('wraps callbacks and reports callback errors', () => {
const callback = vi.fn()
const error = new Error('callback failed')
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const result = convertContextMenuToOptions(
[
{ content: 'Run', value: 'run-value', callback },
{
content: 'Broken',
callback: () => {
throw error
}
},
{ content: 'Disabled', disabled: true, callback: () => {} }
],
undefined,
false
)
result[0].action?.()
result[1].action?.()
expect(callback).toHaveBeenCalledWith(
'run-value',
{},
undefined,
undefined,
expect.objectContaining({ content: 'Run' })
)
expect(errorSpy).toHaveBeenCalledWith(
'Error executing context menu callback:',
error
)
expect(result[2].action).toBeUndefined()
errorSpy.mockRestore()
})
it('converts static submenus and submenu callbacks', () => {
const submenuCallback = vi.fn()
const error = new Error('submenu failed')
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const result = convertContextMenuToOptions(
[
{
content: 'Static Submenu',
has_submenu: true,
submenu: {
options: [
'<b>ignored string without callback</b>',
null,
{
content: '<b>Choice</b>',
value: 'choice',
callback: submenuCallback
},
{
content: '<i>Disabled</i>',
disabled: true
},
{
content: '<span>Broken</span>',
callback: () => {
throw error
}
},
{ content: '' }
]
}
}
],
undefined,
false
)
const submenu = result[0].submenu ?? []
expect(result[0].hasSubmenu).toBe(true)
expect(submenu.map((option) => option.label)).toEqual([
'<b>ignored string without callback</b>',
'Choice',
'Disabled',
'Broken'
])
expect(submenu[2].disabled).toBe(true)
submenu[1].action?.()
submenu[3].action?.()
expect(submenuCallback).toHaveBeenCalledWith(
'choice',
{},
undefined,
undefined,
expect.objectContaining({ content: '<b>Choice</b>' })
)
expect(errorSpy).toHaveBeenCalledWith(
'Error executing submenu callback:',
error
)
errorSpy.mockRestore()
})
it('captures dynamic submenus created by callbacks', () => {
const stringCallback = vi.fn()
const objectCallback = vi.fn()
const result = convertContextMenuToOptions(
[
{
content: 'Dynamic Submenu',
has_submenu: true,
callback: () => {
new LiteGraph.ContextMenu(
[
'Auto',
{
content: '<b>Object choice</b>',
value: 'object',
callback: objectCallback
}
],
{ callback: stringCallback, extra: { source: 'test' } }
)
}
}
],
undefined,
false
)
const submenu = result[0].submenu ?? []
expect(result[0].hasSubmenu).toBe(true)
expect(submenu.map((option) => option.label)).toEqual([
'Auto',
'Object choice'
])
submenu[0].action?.()
submenu[1].action?.()
expect(stringCallback).toHaveBeenCalledWith(
'Auto',
expect.objectContaining({ extra: { source: 'test' } }),
undefined,
undefined,
{ source: 'test' }
)
expect(objectCallback).toHaveBeenCalledWith(
'object',
{},
undefined,
undefined,
expect.objectContaining({ content: '<b>Object choice</b>' })
)
})
it('warns when dynamic submenu callbacks fail to provide items', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const result = convertContextMenuToOptions(
[
{
content: 'Empty Dynamic Submenu',
has_submenu: true,
callback: () => {}
}
],
undefined,
false
)
expect(result[0].hasSubmenu).toBe(true)
expect(result[0].submenu).toBeUndefined()
expect(warnSpy).toHaveBeenCalledWith(
'[ContextMenuConverter] No items captured for:',
'Empty Dynamic Submenu'
)
expect(warnSpy).toHaveBeenCalledWith(
'[ContextMenuConverter] Failed to capture submenu for:',
'Empty Dynamic Submenu'
)
warnSpy.mockRestore()
})
})
})

View File

@@ -20,7 +20,6 @@ import * as missingModelScan from '@/platform/missingModel/missingModelScan'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { app } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
@@ -131,39 +130,6 @@ describe('Connection error clearing via onConnectionsChange', () => {
expect(store.lastNodeErrors).not.toBeNull()
})
it('does not clear errors when a connected input has no root graph', () => {
const { graph, node } = createGraphWithInput()
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'clip'
)
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
expect(store.lastNodeErrors).not.toBeNull()
})
it('does not clear errors when a connected input has no slot name', () => {
const { graph, node } = createGraphWithInput()
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'clip'
)
node.onConnectionsChange!(NodeSlotType.INPUT, 12, true, null, fromAny(null))
expect(store.lastNodeErrors).not.toBeNull()
})
it('clears errors for pure input slots without widget property', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
@@ -286,36 +252,6 @@ describe('Widget change error clearing via onWidgetChanged', () => {
expect(store.lastNodeErrors).not.toBeNull()
})
it('does not clear errors when the host execution id is unavailable', () => {
const graph = new LGraph()
const otherGraph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('number', 'steps', 20, () => undefined, {})
graph.add(node)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(otherGraph)
store.lastNodeErrors = {
[String(node.id)]: {
errors: [
{
type: 'value_bigger_than_max',
message: 'Too big',
details: '',
extra_info: { input_name: 'steps' }
}
],
dependent_outputs: [],
class_type: 'TestNode'
}
}
node.onWidgetChanged!.call(node, 'steps', 50, 20, node.widgets![0])
expect(store.lastNodeErrors).not.toBeNull()
})
it('clears missing media when an upload emits onWidgetChanged', () => {
const graph = new LGraph()
const node = new LGraphNode('LoadImage')
@@ -455,124 +391,6 @@ describe('installErrorClearingHooks lifecycle', () => {
expect(node.onConnectionsChange).toBe(chainedAfterFirst)
})
it('removes unhooked nodes without restoring callbacks', () => {
const graph = new LGraph()
installErrorClearingHooks(graph)
const node = new LGraphNode('late')
expect(() => graph.onNodeRemoved!(node)).not.toThrow()
expect(node.onConnectionsChange).toBeUndefined()
expect(node.onWidgetChanged).toBeUndefined()
})
it('restores recursively installed callbacks on subgraph cleanup', () => {
const subgraph = createTestSubgraph()
const innerNode = new LGraphNode('inner')
const originalOnConnectionsChange = vi.fn()
const originalOnWidgetChanged = vi.fn()
innerNode.onConnectionsChange = originalOnConnectionsChange
innerNode.onWidgetChanged = originalOnWidgetChanged
subgraph.add(innerNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const graph = subgraph.rootGraph
graph.add(subgraphNode)
const cleanup = installErrorClearingHooks(graph)
expect(innerNode.onConnectionsChange).not.toBe(originalOnConnectionsChange)
expect(innerNode.onWidgetChanged).not.toBe(originalOnWidgetChanged)
cleanup()
expect(innerNode.onConnectionsChange).toBe(originalOnConnectionsChange)
expect(innerNode.onWidgetChanged).toBe(originalOnWidgetChanged)
})
it('restores undefined graph hooks when cleanup is called', () => {
const graph = new LGraph()
const cleanup = installErrorClearingHooks(graph)
cleanup()
expect(graph.onNodeAdded).toBeUndefined()
expect(graph.onNodeRemoved).toBeUndefined()
expect(graph.onTrigger).toBeUndefined()
})
it('calls original graph hooks for added, removed, and trigger events', () => {
const graph = new LGraph()
const onNodeAdded = vi.fn()
const onNodeRemoved = vi.fn()
const onTrigger = vi.fn()
graph.onNodeAdded = onNodeAdded
graph.onNodeRemoved = onNodeRemoved
graph.onTrigger = onTrigger
installErrorClearingHooks(graph)
const node = new LGraphNode('test')
graph.onNodeAdded!(node)
graph.onNodeRemoved!(node)
graph.onTrigger!({
type: 'node:property:changed',
nodeId: node.id,
property: 'title',
oldValue: 'old',
newValue: 'new'
})
expect(onNodeAdded).toHaveBeenCalledWith(node)
expect(onNodeRemoved).toHaveBeenCalledWith(node)
expect(onTrigger).toHaveBeenCalledWith(
expect.objectContaining({ property: 'title' })
)
})
it('skips scanning added nodes while graph loading is in progress', async () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
vi.spyOn(ChangeTracker, 'isLoadingGraph', 'get').mockReturnValue(true)
installErrorClearingHooks(graph)
const node = new LGraphNode('CheckpointLoaderSimple')
graph.add(node)
await Promise.resolve()
await Promise.resolve()
expect(modelScan).not.toHaveBeenCalled()
})
it('skips scanning added nodes when root graph is unavailable', async () => {
const graph = new LGraph()
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
const mediaScan = vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates')
installErrorClearingHooks(graph)
graph.add(new LGraphNode('CheckpointLoaderSimple'))
await Promise.resolve()
await Promise.resolve()
expect(modelScan).not.toHaveBeenCalled()
expect(mediaScan).not.toHaveBeenCalled()
})
it('skips scanning added inactive nodes', async () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
installErrorClearingHooks(graph)
const node = new LGraphNode('CheckpointLoaderSimple')
node.mode = LGraphEventMode.BYPASS
graph.add(node)
await Promise.resolve()
await Promise.resolve()
expect(modelScan).not.toHaveBeenCalled()
})
it('scans added-node missing models after widget values are restored', async () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
@@ -916,84 +734,6 @@ describe('realtime scan verifies pending cloud candidates', () => {
await new Promise((r) => setTimeout(r, 0))
expect(useMissingModelStore().missingModelCandidates).toBeNull()
})
it('logs pending model verification failures without surfacing candidates', async () => {
const graph = new LGraph()
const node = new LGraphNode('CheckpointLoaderSimple')
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
{
nodeId: String(node.id),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
name: 'broken.safetensors',
isMissing: undefined
}
])
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
const verifySpy = vi
.spyOn(missingModelScan, 'verifyAssetSupportedCandidates')
.mockRejectedValue(new Error('nope'))
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined)
installErrorClearingHooks(graph)
node.mode = LGraphEventMode.ALWAYS
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: node.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
await vi.waitFor(() => expect(warnSpy).toHaveBeenCalledOnce())
expect(useMissingModelStore().missingModelCandidates).toBeNull()
})
it('logs pending media verification failures without surfacing candidates', async () => {
const graph = new LGraph()
const node = new LGraphNode('LoadImage')
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([
{
nodeId: String(node.id),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'broken.png',
isMissing: undefined
}
])
const verifySpy = vi
.spyOn(missingMediaScan, 'verifyMediaCandidates')
.mockRejectedValue(new Error('nope'))
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined)
installErrorClearingHooks(graph)
node.mode = LGraphEventMode.ALWAYS
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: node.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
await vi.waitFor(() => expect(warnSpy).toHaveBeenCalledOnce())
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
})
})
describe('realtime verification staleness guards', () => {
@@ -1153,54 +893,6 @@ describe('realtime verification staleness guards', () => {
// result must not be added to the store.
expect(useMissingModelStore().missingModelCandidates).toBeNull()
})
it('skips adding verified media when rootGraph switched before verification resolved', async () => {
const graphA = new LGraph()
const nodeA = new LGraphNode('LoadImage')
graphA.add(nodeA)
const rootSpy = vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graphA)
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([
{
nodeId: String(nodeA.id),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'stale_from_A.png',
isMissing: undefined
}
])
let resolveVerify: (() => void) | undefined
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
const verifySpy = vi
.spyOn(missingMediaScan, 'verifyMediaCandidates')
.mockImplementation(async (candidates) => {
await verifyPromise
for (const c of candidates) c.isMissing = true
})
installErrorClearingHooks(graphA)
nodeA.mode = LGraphEventMode.ALWAYS
graphA.onTrigger?.({
type: 'node:property:changed',
nodeId: nodeA.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
const graphB = new LGraph()
graphB.add(new LGraphNode('LoadImage'))
rootSpy.mockReturnValue(graphB)
resolveVerify!()
await new Promise((r) => setTimeout(r, 0))
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
})
})
describe('scan skips interior of bypassed subgraph containers', () => {
@@ -1312,167 +1004,6 @@ describe('scan skips interior of bypassed subgraph containers', () => {
)
})
it('skips inactive descendants during subgraph replay scans', async () => {
const rootGraph = new LGraph()
const subgraph = createTestSubgraph({ rootGraph })
const activeNode = new LGraphNode('UNETLoader')
const bypassedNode = new LGraphNode('CheckpointLoaderSimple')
bypassedNode.mode = LGraphEventMode.BYPASS
subgraph.add(activeNode)
subgraph.add(bypassedNode)
const subgraphNode = createTestSubgraphNode(subgraph, {
parentGraph: rootGraph,
id: 205
})
rootGraph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
const modelScanSpy = vi
.spyOn(missingModelScan, 'scanNodeModelCandidates')
.mockReturnValue([])
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
installErrorClearingHooks(rootGraph)
rootGraph.onNodeAdded?.(subgraphNode)
await Promise.resolve()
expect(modelScanSpy).toHaveBeenCalledWith(
rootGraph,
activeNode,
expect.any(Function),
expect.any(Function)
)
expect(modelScanSpy).not.toHaveBeenCalledWith(
rootGraph,
bypassedNode,
expect.any(Function),
expect.any(Function)
)
})
it('surfaces missing node errors from the Unknown fallback type', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.type = fromAny<LGraphNode['type'], unknown>(undefined)
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
installErrorClearingHooks(graph)
node.mode = LGraphEventMode.ALWAYS
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: node.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
expect(useMissingNodesErrorStore().missingNodesError?.nodeTypes).toEqual([
expect.objectContaining({ type: 'Unknown', nodeId: String(node.id) })
])
})
it('does not show the overlay when un-bypass finds no missing errors', () => {
const subgraph = createTestSubgraph()
const node = createTestSubgraphNode(subgraph)
const graph = subgraph.rootGraph
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
const showOverlay = vi.spyOn(useExecutionErrorStore(), 'showErrorOverlay')
installErrorClearingHooks(graph)
node.mode = LGraphEventMode.ALWAYS
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: node.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
expect(showOverlay).not.toHaveBeenCalled()
})
it('ignores mode changes that do not change active state', () => {
const graph = new LGraph()
const node = new LGraphNode('CheckpointLoaderSimple')
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
installErrorClearingHooks(graph)
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: node.id,
property: 'mode',
oldValue: LGraphEventMode.ALWAYS,
newValue: LGraphEventMode.ON_EVENT
})
expect(modelScan).not.toHaveBeenCalled()
})
it('ignores mode changes for missing local nodes', () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
installErrorClearingHooks(graph)
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: 999,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
expect(modelScan).not.toHaveBeenCalled()
})
it('ignores mode changes when root graph is unavailable', () => {
const graph = new LGraph()
const node = new LGraphNode('CheckpointLoaderSimple')
graph.add(node)
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
installErrorClearingHooks(graph)
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: node.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
expect(modelScan).not.toHaveBeenCalled()
})
it('ignores mode changes when the local node has no root execution id', () => {
const graph = new LGraph()
const rootGraph = new LGraph()
const node = new LGraphNode('CheckpointLoaderSimple')
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
installErrorClearingHooks(graph)
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: node.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
expect(modelScan).not.toHaveBeenCalled()
})
it('removes host-keyed promoted missing models when a source ancestor is bypassed', () => {
const { rootGraph, outerSubgraph, innerSubgraphNode } =
createNestedSubgraphRuntime()

View File

@@ -1,124 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockSelectionState = vi.hoisted(() => ({
refs: null as null | {
hasMultipleSelection: { value: boolean }
}
}))
const mockSettingStore = vi.hoisted(() => ({
get: vi.fn()
}))
const mockTitleEditorStore = vi.hoisted(() => ({
titleEditorTarget: null as null | object
}))
const mockApp = vi.hoisted(() => ({
canvas: {
selectedItems: new Set<object>(),
graph: {
add: vi.fn()
}
}
}))
const mockGroups = vi.hoisted(() => ({
instances: [] as Array<{
resizeTo: ReturnType<typeof vi.fn>
}>
}))
vi.mock('@/composables/graph/useSelectionState', async () => {
const { ref } = await import('vue')
const hasMultipleSelection = ref(false)
mockSelectionState.refs = {
hasMultipleSelection
}
return {
useSelectionState: () => ({
hasMultipleSelection
})
}
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => mockSettingStore
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useTitleEditorStore: () => mockTitleEditorStore
}))
vi.mock('@/scripts/app', () => ({
app: mockApp
}))
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LGraphGroup: class MockLGraphGroup {
resizeTo = vi.fn()
constructor() {
mockGroups.instances.push(this)
}
}
}))
describe('useFrameNodes', () => {
beforeEach(() => {
vi.clearAllMocks()
if (mockSelectionState.refs) {
mockSelectionState.refs.hasMultipleSelection.value = false
}
mockSettingStore.get.mockReturnValue(24)
mockTitleEditorStore.titleEditorTarget = null
mockApp.canvas.selectedItems = new Set()
mockApp.canvas.graph = {
add: vi.fn()
}
mockGroups.instances = []
})
it('exposes whether selected nodes can be framed', async () => {
const { useFrameNodes } = await import('./useFrameNodes')
const { canFrame } = useFrameNodes()
expect(canFrame.value).toBe(false)
if (!mockSelectionState.refs) {
throw new Error('selection refs were not initialized')
}
mockSelectionState.refs.hasMultipleSelection.value = true
expect(canFrame.value).toBe(true)
})
it('does nothing when no items are selected', async () => {
const { useFrameNodes } = await import('./useFrameNodes')
const { frameNodes } = useFrameNodes()
frameNodes()
expect(mockGroups.instances).toHaveLength(0)
expect(mockApp.canvas.graph.add).not.toHaveBeenCalled()
})
it('frames selected items and opens the title editor on the new group', async () => {
const selectedNode = {}
mockApp.canvas.selectedItems = new Set([selectedNode])
const { useFrameNodes } = await import('./useFrameNodes')
const { frameNodes } = useFrameNodes()
frameNodes()
const group = mockGroups.instances[0]
expect(group.resizeTo).toHaveBeenCalledWith(
mockApp.canvas.selectedItems,
24
)
expect(mockApp.canvas.graph.add).toHaveBeenCalledWith(group)
expect(mockTitleEditorStore.titleEditorTarget).toBe(group)
})
})

View File

@@ -1,14 +1,9 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'
import {
extractVueNodeData,
getControlWidget,
useGraphNodeManager
} from '@/composables/graph/useGraphNodeManager'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { widgetId } from '@/types/widgetId'
import {
@@ -19,10 +14,8 @@ import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
describe('Node Reactivity', () => {
beforeEach(() => {
@@ -270,26 +263,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
expect(widgetData.slotMetadata).toBeUndefined()
})
it('maps widget slot metadata even when the input slot name is empty', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
const input = node.addInput('', 'STRING')
input.widget = { name: 'prompt' }
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const widgetData = vueNodeData
.get(node.id)
?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData?.slotMetadata).toMatchObject({
index: 0,
linked: false,
type: 'STRING'
})
})
})
describe('Subgraph output slot label reactivity', () => {
@@ -783,535 +756,3 @@ describe('Pre-remove vueNodeData drain', () => {
).toBe(0)
})
})
describe('Graph node manager property triggers', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('updates Vue node data for LiteGraph property change events', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'title',
newValue: 'Renamed'
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'has_errors',
newValue: true
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'flags.collapsed',
newValue: true
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'flags.ghost',
newValue: true
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'flags.pinned',
newValue: true
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'mode',
newValue: 4
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'color',
newValue: '#123456'
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'bgcolor',
newValue: '#abcdef'
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'shape',
newValue: 2
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'showAdvanced',
newValue: true
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'badges',
newValue: [{ text: 'hot' }]
})
expect(vueNodeData.get(node.id)).toMatchObject({
title: 'Renamed',
hasErrors: true,
flags: {
collapsed: true,
ghost: true,
pinned: true
},
mode: 4,
color: '#123456',
bgcolor: '#abcdef',
shape: 2,
showAdvanced: true,
badges: [{ text: 'hot' }]
})
})
it('normalizes invalid property payloads to safe Vue node data', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'mode',
newValue: 'invalid'
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'color',
newValue: false
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'bgcolor',
newValue: 123
})
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'shape',
newValue: 'round'
})
expect(vueNodeData.get(node.id)).toMatchObject({
mode: 0,
color: undefined,
bgcolor: undefined,
shape: undefined
})
})
it('ignores property events for nodes the manager does not track', () => {
const graph = new LGraph()
useGraphNodeManager(graph)
expect(() =>
graph.trigger('node:property:changed', {
nodeId: 'missing',
property: 'title',
newValue: 'ignored'
})
).not.toThrow()
})
it('ignores non-input slot link events and refreshes slot error metadata', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('string', 'prompt', 'hello', () => undefined)
const input = node.addInput('prompt', 'STRING')
input.widget = { name: 'prompt' }
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const widgetData = vueNodeData
.get(node.id)
?.widgets?.find((w) => w.name === 'prompt')
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.OUTPUT
})
expect(widgetData?.slotMetadata?.linked).toBe(false)
input.link = fromAny(123)
graph.trigger('node:slot-errors:changed', {
nodeId: node.id
})
expect(widgetData?.slotMetadata?.linked).toBe(true)
})
})
describe('extractVueNodeData widget mapping', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('normalizes widget callback values and redraws sibling widgets', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
const callback = vi.fn()
const siblingTriggerDraw = vi.fn()
node.addWidget('string', 'prompt', 'hello', callback)
node.addCustomWidget(
fromAny<IBaseWidget, unknown>({
name: 'sibling',
type: 'text',
value: '',
options: {},
triggerDraw: siblingTriggerDraw
})
)
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const widgetData = vueNodeData
.get(node.id)
?.widgets?.find((widget) => widget.name === 'prompt')
if (!widgetData?.callback) throw new Error('Missing widget callback')
widgetData.callback(null)
expect(node.widgets![0].value).toBeUndefined()
widgetData.callback('text')
expect(node.widgets![0].value).toBe('text')
widgetData.callback(3)
expect(node.widgets![0].value).toBe(3)
widgetData.callback(true)
expect(node.widgets![0].value).toBe(true)
const objectValue = { nested: true }
widgetData.callback(objectValue)
expect(node.widgets![0].value).toStrictEqual(objectValue)
const fileValues = [new File(['x'], 'x.txt')]
widgetData.callback(fileValues)
expect(node.widgets![0].value).toHaveLength(1)
expect((node.widgets![0].value as File[])[0]).toBeInstanceOf(File)
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
widgetData.callback(Symbol('invalid'))
expect(node.widgets![0].value).toBeUndefined()
expect(callback).toHaveBeenLastCalledWith(undefined, app.canvas, node)
expect(siblingTriggerDraw).toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalledWith(
'Invalid widget value type: symbol',
expect.any(Symbol)
)
warnSpy.mockRestore()
})
it('extracts display, DOM, layout, tooltip, and duplicate widget metadata', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addCustomWidget({
name: 'plain',
type: 'text',
value: 'a'
} as IBaseWidget)
node.addCustomWidget(
fromAny<IBaseWidget, unknown>({
name: 'plain',
type: 'text',
value: 'b',
advanced: true,
element: document.createElement('input'),
computeLayoutSize: () => ({ minWidth: 1, minHeight: 1 }),
options: {
canvasOnly: true,
hidden: true,
read_only: true
},
tooltip: 'Details'
})
)
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const widgets = vueNodeData.get(node.id)?.widgets
expect(widgets?.[0]?.options).toBeUndefined()
expect(widgets?.[1]).toMatchObject({
name: 'plain',
type: 'text',
hasLayoutSize: true,
isDOMWidget: true,
tooltip: 'Details',
options: {
canvasOnly: true,
advanced: true,
hidden: true,
read_only: true
}
})
expect(widgets?.[0]?.widgetId).toBeDefined()
expect(widgets?.[1]?.widgetId).toBeDefined()
})
it('falls back to safe widget data when a widget mapper throws', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const node = new LGraphNode('test')
const badWidget = fromAny<IBaseWidget, unknown>({
name: 'broken',
type: 'custom',
value: 'x',
get options() {
throw new Error('bad options')
}
})
node.widgets = [badWidget]
const data = extractVueNodeData(node)
expect(data.widgets?.[0]).toEqual({ name: 'broken', type: 'custom' })
expect(warnSpy).toHaveBeenCalledWith(
'[safeWidgetMapper] Failed to map widget:',
'broken',
expect.any(Error)
)
warnSpy.mockRestore()
})
it('falls back to unknown widget data when a broken widget has no name or type', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const node = new LGraphNode('test')
const badWidget = fromAny<IBaseWidget, unknown>({
value: 'x',
get options() {
throw new Error('bad options')
}
})
node.widgets = [badWidget]
const data = extractVueNodeData(node)
expect(data.widgets?.[0]).toEqual({ name: 'unknown', type: 'text' })
warnSpy.mockRestore()
})
it('keeps custom widgets getter results in sync', () => {
const node = new LGraphNode('test')
let widgets = [
{
name: 'first',
type: 'text',
value: 'one',
options: {}
} as IBaseWidget
]
Object.defineProperty(node, 'widgets', {
get() {
return widgets
},
configurable: true
})
const data = extractVueNodeData(node)
expect(data.widgets?.map((widget) => widget.name)).toEqual(['first'])
widgets = [
{
name: 'second',
type: 'text',
value: 'two',
options: {}
} as IBaseWidget
]
expect(node.widgets?.map((widget) => widget.name)).toEqual(['second'])
expect(data.widgets?.map((widget) => widget.name)).toEqual(['second'])
})
it('treats undefined custom widget getter results as an empty widget list', () => {
const node = new LGraphNode('test')
Object.defineProperty(node, 'widgets', {
get() {
return undefined
},
configurable: true
})
const data = extractVueNodeData(node)
expect(data.widgets?.length).toBe(0)
})
it('derives node type fallbacks and subgraph id from graph context', () => {
const node = new LGraphNode('')
node.type = ''
Object.defineProperty(node, 'constructor', {
value: { title: 'FallbackTitle', nodeData: { api_node: true } },
configurable: true
})
node.graph = {
id: 'subgraph-id',
rootGraph: new LGraph()
} as LGraph
const data = extractVueNodeData(node)
expect(data.type).toBe('FallbackTitle')
expect(data.subgraphId).toBe('subgraph-id')
expect(data.apiNode).toBe(true)
})
it('preserves flags when extracting Vue node data', () => {
const node = new LGraphNode('test')
node.flags = { collapsed: true, pinned: true }
const data = extractVueNodeData(node)
expect(data.flags).toEqual({ collapsed: true, pinned: true })
})
it('keeps existing promoted widget state when mapping host widgets', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
const interiorInput = interiorNode.addInput('ckpt_input', '*')
interiorNode.addWidget(
'combo',
'ckpt_name',
'source.safetensors',
() => undefined,
{
values: ['source.safetensors']
}
)
interiorInput.widget = { name: 'ckpt_name' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const id = subgraphNode.inputs[0].widgetId
if (!id) throw new Error('Expected promoted input to have widgetId')
const widgetStore = useWidgetValueStore()
if (widgetStore.getWidget(id)) {
widgetStore.setValue(id, 'existing.safetensors')
} else {
widgetStore.registerWidget(id, {
type: 'combo',
value: 'existing.safetensors',
options: {},
label: 'Existing'
})
}
useGraphNodeManager(graph)
expect(widgetStore.getWidget(id)?.value).toBe('existing.safetensors')
})
})
describe('Graph node manager lifecycle hooks', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('defers layout extraction until graph configuration completes', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.title = 'Before'
const originalOnNodeAdded = vi.fn()
const originalAfterConfigured = vi.fn()
graph.onNodeAdded = originalOnNodeAdded
node.onAfterGraphConfigured = originalAfterConfigured
const originalWindowApp = window.app
window.app = { configuringGraph: true } as Window['app']
try {
const { vueNodeData } = useGraphNodeManager(graph)
graph.add(node)
expect(originalOnNodeAdded).toHaveBeenCalledWith(node)
expect(vueNodeData.get(node.id)?.title).toBe('Before')
node.title = 'After'
node.onAfterGraphConfigured?.()
expect(originalAfterConfigured).toHaveBeenCalled()
expect(vueNodeData.get(node.id)?.title).toBe('After')
} finally {
window.app = originalWindowApp
}
})
it('chains original remove and trigger handlers, then restores them on cleanup', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
const originalOnNodeAdded = vi.fn()
const originalOnNodeRemoved = vi.fn()
const originalOnTrigger = vi.fn()
graph.onNodeAdded = originalOnNodeAdded
graph.onNodeRemoved = originalOnNodeRemoved
graph.onTrigger = originalOnTrigger
const manager = useGraphNodeManager(graph)
graph.add(node)
graph.trigger('node:property:changed', {
nodeId: node.id,
property: 'title',
newValue: 'Renamed'
})
graph.remove(node)
expect(originalOnNodeAdded).toHaveBeenCalledWith(node)
expect(originalOnTrigger).toHaveBeenCalledWith(
expect.objectContaining({ type: 'node:property:changed' })
)
expect(originalOnNodeRemoved).toHaveBeenCalledWith(node)
expect(manager.vueNodeData.size).toBe(0)
manager.cleanup()
expect(graph.onNodeAdded).toBe(originalOnNodeAdded)
expect(graph.onNodeRemoved).toBe(originalOnNodeRemoved)
expect(graph.onTrigger).toBe(originalOnTrigger)
})
it('cleans up to undefined when no original callbacks existed', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
const manager = useGraphNodeManager(graph)
expect(manager.vueNodeData.has(node.id)).toBe(true)
manager.cleanup()
expect(graph.onNodeAdded).toBeUndefined()
expect(graph.onNodeRemoved).toBeUndefined()
expect(graph.onTrigger).toBeUndefined()
expect(manager.vueNodeData.size).toBe(0)
})
})
describe('getControlWidget', () => {
it('normalizes linked control widget values and updates the source widget', () => {
const linkedControl = {
[IS_CONTROL_WIDGET]: true,
value: 'fixed'
}
const widget = {
linkedWidgets: [linkedControl]
} as unknown as IBaseWidget
const control = getControlWidget(widget)
expect(control?.value).toBe('fixed')
control?.update('unexpected')
expect(linkedControl.value).toBe('randomize')
})
})

View File

@@ -1,215 +0,0 @@
import type * as VueI18n from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphGroup } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useGroupMenuOptions } from '@/composables/graph/useGroupMenuOptions'
const { canvas, captureCanvasState, isLightTheme, refreshCanvas, settings } =
vi.hoisted(() => ({
canvas: { setDirty: vi.fn() },
captureCanvasState: vi.fn(),
isLightTheme: { value: false },
refreshCanvas: vi.fn(),
settings: { 'Comfy.GroupSelectedNodes.Padding': 10 } as Record<
string,
unknown
>
}))
vi.mock('vue-i18n', async (importOriginal) => ({
...(await importOriginal<typeof VueI18n>()),
useI18n: () => ({ t: (key: string) => key })
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: (k: string) => settings[k] })
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: { changeTracker: { captureCanvasState } }
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas })
}))
vi.mock('@/composables/graph/useCanvasRefresh', () => ({
useCanvasRefresh: () => ({ refreshCanvas })
}))
vi.mock('@/composables/graph/useNodeCustomization', () => ({
useNodeCustomization: () => ({
shapeOptions: [{ value: 1, localizedName: 'Box' }],
colorOptions: [
{ value: { dark: '#111', light: '#eee' }, localizedName: 'Red' }
],
isLightTheme
})
}))
function group(over: Record<string, unknown> = {}): LGraphGroup {
return {
recomputeInsideNodes: vi.fn(),
resizeTo: vi.fn(),
children: [],
graph: { change: vi.fn() },
nodes: [],
...over
} as unknown as LGraphGroup
}
beforeEach(() => {
canvas.setDirty.mockReset()
captureCanvasState.mockReset()
isLightTheme.value = false
refreshCanvas.mockReset()
})
describe('useGroupMenuOptions', () => {
it('fits a group to its nodes, resizing with the configured padding', () => {
const g = group()
useGroupMenuOptions().getFitGroupToNodesOption(g).action?.()
expect(g.recomputeInsideNodes).toHaveBeenCalled()
expect(g.resizeTo).toHaveBeenCalledWith(g.children, 10)
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('aborts the fit action when recompute throws', () => {
const g = group({
recomputeInsideNodes: vi.fn(() => {
throw new Error('boom')
})
})
useGroupMenuOptions().getFitGroupToNodesOption(g).action?.()
expect(g.resizeTo).not.toHaveBeenCalled()
})
it('applies a shape to all group nodes via the shape submenu', () => {
const node = { shape: 0, mode: LGraphEventMode.ALWAYS }
const bump = vi.fn()
const option = useGroupMenuOptions().getGroupShapeOptions(
group({ nodes: [node] }),
bump
)
option.submenu?.[0].action?.()
expect(node.shape).toBe(1)
expect(refreshCanvas).toHaveBeenCalled()
expect(bump).toHaveBeenCalled()
})
it('handles shape actions when a group has no nodes array', () => {
const bump = vi.fn()
useGroupMenuOptions()
.getGroupShapeOptions(group({ nodes: undefined }), bump)
.submenu?.[0].action?.()
expect(refreshCanvas).toHaveBeenCalled()
expect(bump).toHaveBeenCalled()
})
it('applies a color to the group via the color submenu (dark theme)', () => {
const g = group()
const bump = vi.fn()
useGroupMenuOptions().getGroupColorOptions(g, bump).submenu?.[0].action?.()
expect((g as unknown as { color: string }).color).toBe('#111')
expect(bump).toHaveBeenCalled()
})
it('applies a light-theme color to the group via the color submenu', () => {
const g = group()
const bump = vi.fn()
isLightTheme.value = true
useGroupMenuOptions().getGroupColorOptions(g, bump).submenu?.[0].action?.()
expect((g as unknown as { color: string }).color).toBe('#eee')
expect(bump).toHaveBeenCalled()
})
it('returns no mode options for an empty group', () => {
expect(useGroupMenuOptions().getGroupModeOptions(group(), vi.fn())).toEqual(
[]
)
})
it('returns no mode options when a group has no nodes array', () => {
expect(
useGroupMenuOptions().getGroupModeOptions(
group({ nodes: undefined }),
vi.fn()
)
).toEqual([])
})
it('returns no mode options when recomputing group nodes fails', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const options = useGroupMenuOptions().getGroupModeOptions(
group({
recomputeInsideNodes: vi.fn(() => {
throw new Error('boom')
})
}),
vi.fn()
)
expect(options).toEqual([])
expect(warnSpy).toHaveBeenCalledWith(
'Failed to recompute nodes in group for mode options:',
expect.any(Error)
)
})
it('builds mode options for uniform nodes and applies the new mode', () => {
const node = { shape: 0, mode: LGraphEventMode.ALWAYS }
const bump = vi.fn()
const options = useGroupMenuOptions().getGroupModeOptions(
group({ nodes: [node] }),
bump
)
expect(options.length).toBeGreaterThan(0)
options[0].action?.()
expect(node.mode).not.toBe(LGraphEventMode.ALWAYS)
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(bump).toHaveBeenCalled()
})
it('offers two alternate modes when all nodes are NEVER', () => {
const options = useGroupMenuOptions().getGroupModeOptions(
group({ nodes: [{ mode: LGraphEventMode.NEVER }] }),
vi.fn()
)
expect(options).toHaveLength(2)
})
it('offers two alternate modes when all nodes are BYPASS', () => {
const options = useGroupMenuOptions().getGroupModeOptions(
group({ nodes: [{ mode: LGraphEventMode.BYPASS }] }),
vi.fn()
)
expect(options).toHaveLength(2)
})
it('offers all three modes when nodes have mixed modes', () => {
const options = useGroupMenuOptions().getGroupModeOptions(
group({
nodes: [
{ mode: LGraphEventMode.ALWAYS },
{ mode: LGraphEventMode.NEVER }
]
}),
vi.fn()
)
expect(options).toHaveLength(3)
})
it('offers all three modes when the uniform mode is unknown', () => {
const options = useGroupMenuOptions().getGroupModeOptions(
group({ nodes: [{ mode: 999 }] }),
vi.fn()
)
expect(options).toHaveLength(3)
})
})

View File

@@ -1,7 +1,6 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { useImageMenuOptions } from './useImageMenuOptions'
@@ -20,11 +19,6 @@ vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute: vi.fn() })
}))
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: vi.fn(),
openFileInNewTab: vi.fn()
}))
function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
Object.defineProperty(navigator, 'clipboard', {
value: clipboard,
@@ -33,15 +27,6 @@ function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
})
}
function stubClipboardItem() {
vi.stubGlobal(
'ClipboardItem',
class ClipboardItemStub {
constructor(public readonly items: Record<string, Blob>) {}
}
)
}
function createImageNode(
overrides: Partial<LGraphNode> | Record<string, unknown> = {}
): LGraphNode {
@@ -60,13 +45,8 @@ function createImageNode(
}
describe('useImageMenuOptions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('getImageMenuOptions', () => {
@@ -95,12 +75,6 @@ describe('useImageMenuOptions', () => {
expect(getImageMenuOptions(node)).toEqual([])
})
it('returns empty array when node image capabilities are absent', () => {
const { getImageMenuOptions } = useImageMenuOptions()
expect(getImageMenuOptions(fromPartial<LGraphNode>({}))).toEqual([])
})
it('returns only Paste Image when node has no images but supports paste', () => {
const node = createMockLGraphNode({
imgs: [],
@@ -208,225 +182,4 @@ describe('useImageMenuOptions', () => {
expect(node.pasteFiles).not.toHaveBeenCalled()
})
})
describe('image actions', () => {
it('opens the selected image without preview query params', () => {
const node = createImageNode()
node.imgs![0].src = 'http://localhost/test.png?preview=1&foo=bar'
const { getImageMenuOptions } = useImageMenuOptions()
const openOption = getImageMenuOptions(node).find(
(o) => o.label === 'Open Image'
)
openOption?.action?.()
expect(openFileInNewTab).toHaveBeenCalledWith(
'http://localhost/test.png?foo=bar'
)
})
it('saves the selected image without preview query params', () => {
const node = createImageNode()
node.imgs![0].src = 'http://localhost/test.png?preview=1&foo=bar'
const { getImageMenuOptions } = useImageMenuOptions()
const saveOption = getImageMenuOptions(node).find(
(o) => o.label === 'Save Image'
)
saveOption?.action?.()
expect(downloadFile).toHaveBeenCalledWith(
'http://localhost/test.png?foo=bar'
)
})
it('does not open or save when the active image is missing', () => {
const node = createImageNode({ imageIndex: 1 })
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
options.find((o) => o.label === 'Open Image')?.action?.()
options.find((o) => o.label === 'Save Image')?.action?.()
expect(openFileInNewTab).not.toHaveBeenCalled()
expect(downloadFile).not.toHaveBeenCalled()
})
it('does not run image actions when images are cleared after menu creation', async () => {
const node = createImageNode()
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
node.imgs = []
options.find((o) => o.label === 'Open Image')?.action?.()
await options.find((o) => o.label === 'Copy Image')?.action?.()
options.find((o) => o.label === 'Save Image')?.action?.()
expect(openFileInNewTab).not.toHaveBeenCalled()
expect(downloadFile).not.toHaveBeenCalled()
})
it('does not copy when the active image is missing', async () => {
const node = createImageNode({ imageIndex: 1 })
const write = vi.fn()
mockClipboard(fromPartial<Clipboard>({ write }))
const { getImageMenuOptions } = useImageMenuOptions()
await getImageMenuOptions(node)
.find((o) => o.label === 'Copy Image')
?.action?.()
expect(write).not.toHaveBeenCalled()
})
it('logs save failures for invalid image URLs', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const node = createImageNode()
Object.defineProperty(node.imgs![0], 'src', {
value: 'http://[',
configurable: true
})
const { getImageMenuOptions } = useImageMenuOptions()
getImageMenuOptions(node)
.find((o) => o.label === 'Save Image')
?.action?.()
expect(errorSpy).toHaveBeenCalledWith(
'Failed to save image:',
expect.any(TypeError)
)
expect(downloadFile).not.toHaveBeenCalled()
})
it('copies the selected image to clipboard', async () => {
const node = createImageNode()
const drawImage = vi.fn()
const write = vi.fn().mockResolvedValue(undefined)
stubClipboardItem()
mockClipboard(fromPartial<Clipboard>({ write }))
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
(() =>
fromPartial<CanvasRenderingContext2D>({
drawImage
})) as unknown as HTMLCanvasElement['getContext']
)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
(callback: BlobCallback) => {
callback(new Blob(['image'], { type: 'image/png' }))
}
)
const { getImageMenuOptions } = useImageMenuOptions()
await getImageMenuOptions(node)
.find((o) => o.label === 'Copy Image')
?.action?.()
expect(drawImage).toHaveBeenCalledWith(node.imgs![0], 0, 0)
expect(write).toHaveBeenCalledWith([
expect.objectContaining({
items: { 'image/png': expect.any(Blob) }
})
])
})
it('does not copy when canvas context is unavailable', async () => {
const node = createImageNode()
const write = vi.fn()
mockClipboard(fromPartial<Clipboard>({ write }))
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
(() => null) as HTMLCanvasElement['getContext']
)
const { getImageMenuOptions } = useImageMenuOptions()
await getImageMenuOptions(node)
.find((o) => o.label === 'Copy Image')
?.action?.()
expect(write).not.toHaveBeenCalled()
})
it('does not copy when canvas blob creation fails', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const node = createImageNode()
const write = vi.fn()
mockClipboard(fromPartial<Clipboard>({ write }))
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
(() =>
fromPartial<CanvasRenderingContext2D>({
drawImage: vi.fn()
})) as unknown as HTMLCanvasElement['getContext']
)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
(callback: BlobCallback) => {
callback(null)
}
)
const { getImageMenuOptions } = useImageMenuOptions()
await getImageMenuOptions(node)
.find((o) => o.label === 'Copy Image')
?.action?.()
expect(warnSpy).toHaveBeenCalledWith('Failed to create image blob')
expect(write).not.toHaveBeenCalled()
})
it('does not copy when clipboard write is unavailable', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const node = createImageNode()
mockClipboard(fromPartial<Clipboard>({ write: undefined }))
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
(() =>
fromPartial<CanvasRenderingContext2D>({
drawImage: vi.fn()
})) as unknown as HTMLCanvasElement['getContext']
)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
(callback: BlobCallback) => {
callback(new Blob(['image'], { type: 'image/png' }))
}
)
const { getImageMenuOptions } = useImageMenuOptions()
await getImageMenuOptions(node)
.find((o) => o.label === 'Copy Image')
?.action?.()
expect(warnSpy).toHaveBeenCalledWith('Clipboard API not available')
})
it('logs clipboard copy failures', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const node = createImageNode()
stubClipboardItem()
mockClipboard(
fromPartial<Clipboard>({
write: vi.fn().mockRejectedValue(new Error('blocked'))
})
)
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
(() =>
fromPartial<CanvasRenderingContext2D>({
drawImage: vi.fn()
})) as unknown as HTMLCanvasElement['getContext']
)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
(callback: BlobCallback) => {
callback(new Blob(['image'], { type: 'image/png' }))
}
)
const { getImageMenuOptions } = useImageMenuOptions()
await getImageMenuOptions(node)
.find((o) => o.label === 'Copy Image')
?.action?.()
expect(errorSpy).toHaveBeenCalledWith(
'Failed to copy image to clipboard:',
expect.any(Error)
)
})
})
})

View File

@@ -1,292 +0,0 @@
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphGroup } from '@/lib/litegraph/src/litegraph'
import {
isNodeOptionsOpen,
registerNodeOptionsInstance,
showNodeOptions,
toggleNodeOptions,
useMoreOptionsMenu
} from '@/composables/graph/useMoreOptionsMenu'
const {
canvasState,
extraWidgetOptions,
imageOptions,
nodeMenu,
selectionMenu,
selectionState
} = vi.hoisted(() => ({
canvasState: {
canvas: undefined as
| undefined
| {
getNodeMenuOptions: ReturnType<typeof vi.fn>
}
},
extraWidgetOptions: {
value: [] as Array<{ content: string; callback?: () => void }>
},
imageOptions: {
value: [] as Array<{ label: string; hasSubmenu?: boolean; submenu?: [] }>
},
nodeMenu: {
visualOptions: {
value: [] as Array<{
label: string
hasSubmenu?: boolean
submenu?: Array<{ label: string; action: () => void }>
}>
}
},
selectionMenu: {
basicOptions: { value: [{ label: 'Copy' }] },
multipleOptions: { value: [{ label: 'Align' }] },
subgraphOptions: { value: [] as Array<{ label: string }> }
},
selectionState: {
selectedItems: { value: [] as unknown[] },
selectedNodes: { value: [] as unknown[] },
canOpenNodeInfo: { value: false },
openNodeInfo: vi.fn(() => true),
hasSubgraphs: { value: false },
hasImageNode: { value: false },
hasOutputNodesSelected: { value: false },
hasMultipleSelection: { value: false },
computeSelectionFlags: vi.fn(() => ({
collapsed: false,
pinned: false
}))
}
}))
vi.mock('@/composables/graph/useSelectionState', () => ({
useSelectionState: () => selectionState
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => canvasState
}))
vi.mock('@/services/litegraphService', () => ({
getExtraOptionsForWidget: () => extraWidgetOptions.value
}))
vi.mock('@/composables/graph/useImageMenuOptions', () => ({
useImageMenuOptions: () => ({
getImageMenuOptions: () => imageOptions.value
})
}))
vi.mock('@/composables/graph/useNodeMenuOptions', () => ({
useNodeMenuOptions: () => ({
getNodeInfoOption: (openNodeInfo: () => boolean) => ({
label: 'Node Info',
action: openNodeInfo
}),
getNodeVisualOptions: () => nodeMenu.visualOptions.value,
getPinOption: () => ({ label: 'Pin' }),
getBypassOption: () => ({ label: 'Bypass' }),
getRunBranchOption: () => ({ label: 'Run Branch' })
})
}))
vi.mock('@/composables/graph/useGroupMenuOptions', () => ({
useGroupMenuOptions: () => ({
getFitGroupToNodesOption: () => ({ label: 'Fit' }),
getGroupColorOptions: () => ({ label: 'Group Color' }),
getGroupModeOptions: () => [{ label: 'Group Mode' }]
})
}))
vi.mock('@/composables/graph/useSelectionMenuOptions', () => ({
useSelectionMenuOptions: () => ({
getBasicSelectionOptions: () => selectionMenu.basicOptions.value,
getMultipleNodesOptions: () => selectionMenu.multipleOptions.value,
getSubgraphOptions: () => selectionMenu.subgraphOptions.value
})
}))
beforeEach(() => {
vi.clearAllMocks()
registerNodeOptionsInstance(null)
canvasState.canvas = undefined
extraWidgetOptions.value = []
imageOptions.value = []
nodeMenu.visualOptions.value = []
selectionMenu.basicOptions.value = [{ label: 'Copy' }]
selectionMenu.multipleOptions.value = [{ label: 'Align' }]
selectionMenu.subgraphOptions.value = []
selectionState.selectedItems.value = []
selectionState.selectedNodes.value = []
selectionState.canOpenNodeInfo.value = false
selectionState.hasSubgraphs.value = false
selectionState.hasImageNode.value = false
selectionState.hasOutputNodesSelected.value = false
selectionState.hasMultipleSelection.value = false
selectionState.computeSelectionFlags.mockReturnValue({
collapsed: false,
pinned: false
})
})
function labels() {
return useMoreOptionsMenu()
.menuOptions.value.map((o) => o.label)
.filter(Boolean)
}
describe('node options popover instance', () => {
it('reports closed when no instance is registered', () => {
expect(isNodeOptionsOpen()).toBe(false)
})
it('reflects the registered instance open state and forwards toggle/show', () => {
const toggle = vi.fn()
const show = vi.fn()
registerNodeOptionsInstance({
toggle,
show,
hide: vi.fn(),
isOpen: ref(true)
})
expect(isNodeOptionsOpen()).toBe(true)
toggleNodeOptions(new Event('click'))
showNodeOptions(new MouseEvent('contextmenu'))
expect(toggle).toHaveBeenCalled()
expect(show).toHaveBeenCalled()
})
})
describe('useMoreOptionsMenu', () => {
it('assembles a non-empty menu for a single selected node', () => {
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
expect(labels()).toContain('Copy')
expect(labels()).toContain('Pin')
})
it('includes run-branch and multiple-node options for output selections', () => {
const nodes = [
{ id: 1, widgets: [] },
{ id: 2, widgets: [] }
]
selectionState.selectedItems.value = nodes
selectionState.selectedNodes.value = nodes
selectionState.hasOutputNodesSelected.value = true
selectionState.hasMultipleSelection.value = true
const menuLabels = labels()
expect(menuLabels).toContain('Run Branch')
expect(menuLabels).toContain('Align')
})
it('recomputes menu flags after a manual bump', () => {
const { bump, menuOptions } = useMoreOptionsMenu()
void menuOptions.value
expect(selectionState.computeSelectionFlags).toHaveBeenCalledTimes(1)
bump()
void menuOptions.value
expect(selectionState.computeSelectionFlags).toHaveBeenCalledTimes(2)
})
it('assembles group-context options for a single selected group', () => {
const group = new LGraphGroup('Group')
selectionState.selectedItems.value = [group]
selectionState.selectedNodes.value = []
const menuLabels = labels()
expect(menuLabels).toContain('Group Mode')
expect(menuLabels).toContain('Fit')
expect(menuLabels).toContain('Group Color')
})
it('includes node info and visual options for a single node', () => {
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
selectionState.canOpenNodeInfo.value = true
nodeMenu.visualOptions.value = [
{ label: 'Minimize Node' },
{ label: 'Shape', hasSubmenu: true, submenu: [] },
{ label: 'Color', hasSubmenu: true, submenu: [] }
]
const menu = useMoreOptionsMenu().menuOptions.value
expect(menu.map((o) => o.label)).toEqual(
expect.arrayContaining(['Node Info', 'Minimize Node', 'Shape', 'Color'])
)
menu.find((o) => o.label === 'Node Info')?.action?.()
expect(selectionState.openNodeInfo).toHaveBeenCalled()
})
it('returns only entries that have populated submenus', () => {
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
nodeMenu.visualOptions.value = [
{ label: 'Minimize Node' },
{
label: 'Shape',
hasSubmenu: true,
submenu: [{ label: 'Box', action: vi.fn() }]
},
{ label: 'Color', hasSubmenu: true }
]
expect(
useMoreOptionsMenu().menuOptionsWithSubmenu.value.map((o) => o.label)
).toEqual(['Shape'])
})
it('includes image menu options for a selected image node', () => {
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
selectionState.hasImageNode.value = true
imageOptions.value = [{ label: 'Open Image' }]
expect(labels()).toContain('Open Image')
})
it('merges LiteGraph menu options for a single selected node', () => {
const node = { id: 1, widgets: [] }
const getNodeMenuOptions = vi.fn(() => [
{ content: 'Extension Action', callback: vi.fn() }
])
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
canvasState.canvas = { getNodeMenuOptions }
expect(labels()).toContain('Extension Action')
expect(getNodeMenuOptions).toHaveBeenCalledWith(node)
})
it('keeps Vue options when LiteGraph menu construction throws', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
canvasState.canvas = {
getNodeMenuOptions: vi.fn(() => {
throw new Error('boom')
})
}
expect(labels()).toContain('Copy')
expect(errorSpy).toHaveBeenCalledWith(
'Error getting LiteGraph menu items:',
expect.any(Error)
)
})
it('adds hovered widget options to the selected node menu', () => {
const node = { id: 1, widgets: [{ name: 'image' }] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
extraWidgetOptions.value = [{ content: 'Widget Extra', callback: vi.fn() }]
showNodeOptions(new MouseEvent('contextmenu'), 'image')
expect(labels()).toContain('Widget Extra')
})
})

View File

@@ -1,175 +0,0 @@
import type * as VueI18n from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodeCustomization } from '@/composables/graph/useNodeCustomization'
const { selection, refreshCanvas, palette } = vi.hoisted(() => ({
selection: { items: [] as unknown[] },
refreshCanvas: vi.fn(),
palette: { light_theme: false }
}))
vi.mock('vue-i18n', async (importOriginal) => ({
...(await importOriginal<typeof VueI18n>()),
useI18n: () => ({ t: (key: string) => key })
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
get selectedItems() {
return selection.items
}
})
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: () => ({
get completedActivePalette() {
return { light_theme: palette.light_theme }
}
})
}))
vi.mock('@/composables/graph/useCanvasRefresh', () => ({
useCanvasRefresh: () => ({ refreshCanvas })
}))
function colorable(bgcolor?: string) {
return {
setColorOption: vi.fn(),
getColorOption: () => (bgcolor ? { bgcolor } : null)
}
}
beforeEach(() => {
selection.items = []
refreshCanvas.mockReset()
palette.light_theme = false
})
describe('useNodeCustomization', () => {
it('exposes color and shape option lists', () => {
const { colorOptions, shapeOptions } = useNodeCustomization()
expect(colorOptions.length).toBeGreaterThan(1)
expect(shapeOptions.length).toBeGreaterThan(0)
})
it('reflects the active palette light-theme flag', () => {
palette.light_theme = true
expect(useNodeCustomization().isLightTheme.value).toBe(true)
})
it('clears color on all colorable items for the no-color option', () => {
const item = colorable()
selection.items = [item]
useNodeCustomization().applyColor(null)
expect(item.setColorOption).toHaveBeenCalledWith(null)
expect(refreshCanvas).toHaveBeenCalled()
})
it('applies a named color option to colorable items', () => {
const item = colorable()
selection.items = [item]
const { colorOptions, applyColor } = useNodeCustomization()
const named = colorOptions.at(-1)!
applyColor(named)
expect(item.setColorOption).toHaveBeenCalledTimes(1)
expect(item.setColorOption.mock.calls[0][0]).not.toBeNull()
})
it('skips non-colorable items when applying colors', () => {
const item = colorable()
selection.items = [{}, item]
useNodeCustomization().applyColor(null)
expect(item.setColorOption).toHaveBeenCalledWith(null)
expect(refreshCanvas).toHaveBeenCalled()
})
it('returns null current color for an empty selection', () => {
expect(useNodeCustomization().getCurrentColor()).toBeNull()
})
it('returns null current color when no selected item is colorable', () => {
selection.items = [{}]
expect(useNodeCustomization().getCurrentColor()).toBeNull()
})
it('reports a recognized current color', () => {
const { colorOptions, getCurrentColor } = useNodeCustomization()
const named = colorOptions.at(-1)!
selection.items = [colorable(named.value.dark)]
expect(getCurrentColor()?.name).toBe(named.name)
})
it('falls back to the no-color option for an unrecognized current color', () => {
selection.items = [colorable('#not-a-known-color')]
const result = useNodeCustomization().getCurrentColor()
expect(result?.name).toBe('noColor')
})
it('no-ops shape changes when no graph nodes are selected', () => {
selection.items = [colorable()]
const { applyShape, shapeOptions } = useNodeCustomization()
applyShape(shapeOptions[0])
expect(refreshCanvas).not.toHaveBeenCalled()
})
it('returns null current shape with no nodes selected', () => {
expect(useNodeCustomization().getCurrentShape()).toBeNull()
})
it('applies a shape to selected graph nodes and refreshes', () => {
const node = new LGraphNode('Test')
selection.items = [node]
const { applyShape, shapeOptions } = useNodeCustomization()
const target = shapeOptions[0]
applyShape(target)
expect(node.shape).toBe(target.value)
expect(refreshCanvas).toHaveBeenCalled()
})
it('reports the current shape of a selected node', () => {
const node = new LGraphNode('Test')
const { shapeOptions, getCurrentShape } = useNodeCustomization()
node.shape = shapeOptions[0].value
selection.items = [node]
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
})
it('uses the default shape when a selected node has no shape', () => {
const node = new LGraphNode('Test')
Object.defineProperty(node, 'shape', {
value: undefined,
writable: true,
configurable: true
})
const { shapeOptions, getCurrentShape } = useNodeCustomization()
selection.items = [node]
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
})
it('falls back to the default shape for an unknown node shape', () => {
const node = new LGraphNode('Test')
Object.defineProperty(node, 'shape', {
value: 999,
writable: true,
configurable: true
})
const { shapeOptions, getCurrentShape } = useNodeCustomization()
selection.items = [node]
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
})
})

View File

@@ -10,43 +10,30 @@ import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { toNodeId } from '@/types/nodeId'
const { actions, customization } = vi.hoisted(() => ({
actions: {
adjustNodeSize: vi.fn(),
toggleNodeCollapse: vi.fn(),
toggleNodePin: vi.fn(),
toggleNodeBypass: vi.fn(),
runBranch: vi.fn()
},
customization: {
shapeOptions: [] as Array<{ localizedName: string; value: string }>,
colorOptions: [] as Array<{
name: string
localizedName: string
value: { dark: string; light: string }
}>,
applyShape: vi.fn(),
applyColor: vi.fn(),
isLightTheme: { value: false }
}
}))
// canvasStore transitively imports the app singleton; stub it so the real
// ComfyApp module never loads during these unit tests.
vi.mock('@/scripts/app', () => ({
app: { canvas: { selected_nodes: null } }
}))
vi.mock('@/composables/graph/useNodeCustomization', () => ({
useNodeCustomization: () => ({
shapeOptions: customization.shapeOptions,
applyShape: customization.applyShape,
applyColor: customization.applyColor,
colorOptions: customization.colorOptions,
isLightTheme: customization.isLightTheme
shapeOptions: [],
applyShape: vi.fn(),
applyColor: vi.fn(),
colorOptions: [],
isLightTheme: { value: false }
})
}))
vi.mock('@/composables/graph/useSelectedNodeActions', () => ({
useSelectedNodeActions: () => actions
useSelectedNodeActions: () => ({
adjustNodeSize: vi.fn(),
toggleNodeCollapse: vi.fn(),
toggleNodePin: vi.fn(),
toggleNodeBypass: vi.fn(),
runBranch: vi.fn()
})
}))
const i18n = createI18n({
@@ -82,29 +69,9 @@ const getBypassLabel = (selected: LGraphNode[]): string => {
return label
}
function readNodeMenuOptions<T>(
read: (options: ReturnType<typeof useNodeMenuOptions>) => T
): T {
const unread = Symbol('unread')
const result: { value: T | typeof unread } = { value: unread }
const Wrapper = defineComponent({
setup() {
result.value = read(useNodeMenuOptions())
return () => null
}
})
render(Wrapper, { global: { plugins: [i18n] } })
if (result.value === unread) throw new Error('Composable was not read')
return result.value
}
describe('useNodeMenuOptions', () => {
describe('useNodeMenuOptions.getBypassOption', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
customization.shapeOptions = []
customization.colorOptions = []
customization.isLightTheme.value = false
})
it('labels as "Bypass" when no node is bypassed', () => {
@@ -130,109 +97,4 @@ describe('useNodeMenuOptions', () => {
])
).toBe('contextMenu.Bypass')
})
it('labels visual node options from the collapsed state and bumps after action', () => {
const expandBump = vi.fn()
const expand = readNodeMenuOptions(
({ getNodeVisualOptions }) =>
getNodeVisualOptions({ collapsed: true, pinned: false }, expandBump)[0]
)
expect(expand).toMatchObject({
label: 'contextMenu.Expand Node',
icon: 'icon-[lucide--maximize-2]'
})
expand.action?.()
expect(actions.toggleNodeCollapse).toHaveBeenCalledTimes(1)
expect(expandBump).toHaveBeenCalledTimes(1)
const minimize = readNodeMenuOptions(
({ getNodeVisualOptions }) =>
getNodeVisualOptions({ collapsed: false, pinned: false }, vi.fn())[0]
)
expect(minimize).toMatchObject({
label: 'contextMenu.Minimize Node',
icon: 'icon-[lucide--minimize-2]'
})
})
it('labels pin options from the pinned state and bumps after action', () => {
const bump = vi.fn()
const unpin = readNodeMenuOptions(({ getPinOption }) =>
getPinOption({ collapsed: false, pinned: true }, bump)
)
expect(unpin).toMatchObject({
label: 'contextMenu.Unpin',
icon: 'icon-[lucide--pin-off]'
})
unpin.action?.()
expect(actions.toggleNodePin).toHaveBeenCalledTimes(1)
expect(bump).toHaveBeenCalledTimes(1)
const pin = readNodeMenuOptions(({ getPinOption }) =>
getPinOption({ collapsed: false, pinned: false }, vi.fn())
)
expect(pin).toMatchObject({
label: 'contextMenu.Pin',
icon: 'icon-[lucide--pin]'
})
})
it('builds shape and color submenus and applies selected values', () => {
customization.shapeOptions = [{ localizedName: 'Box', value: 'box' }]
customization.colorOptions = [
{
name: 'noColor',
localizedName: 'No Color',
value: { dark: '#000', light: '#fff' }
},
{
name: 'red',
localizedName: 'Red',
value: { dark: '#111', light: '#eee' }
}
]
const { visualOptions, colorSubmenu } = readNodeMenuOptions((options) => ({
visualOptions: options.getNodeVisualOptions(
{ collapsed: false, pinned: false },
vi.fn()
),
colorSubmenu: options.colorSubmenu.value
}))
expect(visualOptions[1].submenu).toEqual([
expect.objectContaining({ label: 'Box' })
])
visualOptions[1].submenu?.[0].action()
expect(customization.applyShape).toHaveBeenCalledWith(
customization.shapeOptions[0]
)
expect(colorSubmenu).toEqual([
expect.objectContaining({ label: 'No Color', color: '#000' }),
expect.objectContaining({ label: 'Red', color: '#111' })
])
colorSubmenu[0].action()
colorSubmenu[1].action()
expect(customization.applyColor).toHaveBeenNthCalledWith(1, null)
expect(customization.applyColor).toHaveBeenNthCalledWith(
2,
customization.colorOptions[1]
)
})
it('uses light-theme colors for the color submenu', () => {
customization.isLightTheme.value = true
customization.colorOptions = [
{
name: 'red',
localizedName: 'Red',
value: { dark: '#111', light: '#eee' }
}
]
expect(
readNodeMenuOptions((options) => options.colorSubmenu.value[0].color)
).toBe('#eee')
})
})

View File

@@ -1,221 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSelectionOperations } from '@/composables/graph/useSelectionOperations'
const {
canvas,
toastAdd,
captureCanvasState,
updateSelectedItems,
prompt,
titleEditor,
store
} = vi.hoisted(() => ({
canvas: {
selectedItems: new Set<unknown>(),
copyToClipboard: vi.fn(),
pasteFromClipboard: vi.fn(),
deleteSelected: vi.fn(),
setDirty: vi.fn()
},
toastAdd: vi.fn(),
captureCanvasState: vi.fn(),
updateSelectedItems: vi.fn(),
prompt: vi.fn(),
titleEditor: { titleEditorTarget: null as unknown },
store: { selectedItems: [] as unknown[] }
}))
vi.mock('@/scripts/app', () => ({ app: { canvas } }))
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: toastAdd })
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: { changeTracker: { captureCanvasState } }
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
updateSelectedItems,
get selectedItems() {
return store.selectedItems
}
}),
useTitleEditorStore: () => titleEditor
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({ prompt })
}))
beforeEach(() => {
canvas.selectedItems = new Set()
canvas.copyToClipboard.mockReset()
canvas.pasteFromClipboard.mockReset()
canvas.deleteSelected.mockReset()
canvas.setDirty.mockReset()
toastAdd.mockReset()
captureCanvasState.mockReset()
updateSelectedItems.mockReset()
prompt.mockReset()
titleEditor.titleEditorTarget = null
store.selectedItems = []
})
describe('useSelectionOperations', () => {
it('warns and does nothing when copying an empty selection', () => {
useSelectionOperations().copySelection()
expect(canvas.copyToClipboard).not.toHaveBeenCalled()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
it('copies a non-empty selection and reports success', () => {
canvas.selectedItems = new Set(['a'])
useSelectionOperations().copySelection()
expect(canvas.copyToClipboard).toHaveBeenCalled()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('pastes from clipboard and captures canvas state', () => {
useSelectionOperations().pasteSelection()
expect(canvas.pasteFromClipboard).toHaveBeenCalledWith({
connectInputs: false
})
expect(captureCanvasState).toHaveBeenCalled()
})
it('duplicates by copy, clear, paste', () => {
canvas.selectedItems = new Set(['a'])
useSelectionOperations().duplicateSelection()
expect(canvas.copyToClipboard).toHaveBeenCalled()
expect(canvas.selectedItems.size).toBe(0)
expect(updateSelectedItems).toHaveBeenCalled()
expect(canvas.pasteFromClipboard).toHaveBeenCalled()
expect(captureCanvasState).toHaveBeenCalled()
})
it('warns when duplicating nothing', () => {
useSelectionOperations().duplicateSelection()
expect(canvas.copyToClipboard).not.toHaveBeenCalled()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
it('deletes a non-empty selection and marks the canvas dirty', () => {
canvas.selectedItems = new Set(['a'])
useSelectionOperations().deleteSelection()
expect(canvas.deleteSelected).toHaveBeenCalled()
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('warns when deleting nothing', () => {
useSelectionOperations().deleteSelection()
expect(canvas.deleteSelected).not.toHaveBeenCalled()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
it('routes a single node rename to the title editor', async () => {
const node = new LGraphNode('Test')
store.selectedItems = [node]
await useSelectionOperations().renameSelection()
expect(titleEditor.titleEditorTarget).toBe(node)
expect(prompt).not.toHaveBeenCalled()
})
it('renames a single non-node item via the prompt dialog', async () => {
const group = { title: 'Old' }
store.selectedItems = [group]
prompt.mockResolvedValue('New')
await useSelectionOperations().renameSelection()
expect(group.title).toBe('New')
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('leaves a single titled item unchanged when the prompt returns the same title', async () => {
const group = { title: 'Old' }
store.selectedItems = [group]
prompt.mockResolvedValue('Old')
await useSelectionOperations().renameSelection()
expect(group.title).toBe('Old')
expect(canvas.setDirty).not.toHaveBeenCalled()
expect(captureCanvasState).not.toHaveBeenCalled()
})
it('does not assign a title to a selected item without a title property', async () => {
const item = {}
store.selectedItems = [item]
prompt.mockResolvedValue('New')
await useSelectionOperations().renameSelection()
expect(item).toEqual({})
expect(canvas.setDirty).not.toHaveBeenCalled()
expect(captureCanvasState).not.toHaveBeenCalled()
})
it('batch-renames multiple items with an indexed base name', async () => {
const a = { title: 'a' }
const b = { title: 'b' }
store.selectedItems = [a, b]
prompt.mockResolvedValue('Item')
await useSelectionOperations().renameSelection()
expect(a.title).toBe('Item 1')
expect(b.title).toBe('Item 2')
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('skips untitled items during batch rename', async () => {
const a = { title: 'a' }
const b = {}
store.selectedItems = [a, b]
prompt.mockResolvedValue('Item')
await useSelectionOperations().renameSelection()
expect(a.title).toBe('Item 1')
expect(b).toEqual({})
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('leaves a multiple selection unchanged when batch rename is cancelled', async () => {
const a = { title: 'a' }
const b = { title: 'b' }
store.selectedItems = [a, b]
prompt.mockResolvedValue('')
await useSelectionOperations().renameSelection()
expect(a.title).toBe('a')
expect(b.title).toBe('b')
expect(canvas.setDirty).not.toHaveBeenCalled()
expect(captureCanvasState).not.toHaveBeenCalled()
})
it('warns when renaming an empty selection', async () => {
await useSelectionOperations().renameSelection()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
})

View File

@@ -8,12 +8,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import {
isImageNode,
isLGraphGroup,
isLGraphNode,
isLoad3dNode
} from '@/utils/litegraphUtil'
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
import {
createMockLGraphNode,
@@ -22,9 +17,7 @@ import {
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(),
isImageNode: vi.fn(),
isLGraphGroup: vi.fn(),
isLoad3dNode: vi.fn()
isImageNode: vi.fn()
}))
vi.mock('@/utils/nodeFilterUtil', () => ({
@@ -103,14 +96,6 @@ describe('useSelectionState', () => {
const typedNode = node as { type?: string }
return typedNode?.type === 'ImageNode'
})
vi.mocked(isLGraphGroup).mockImplementation((item: unknown) => {
const typedItem = item as { isGroup?: boolean }
return typedItem?.isGroup === true
})
vi.mocked(isLoad3dNode).mockImplementation((node: unknown) => {
const typedNode = node as { type?: string }
return typedNode?.type === 'Load3D'
})
vi.mocked(filterOutputNodes).mockImplementation((nodes) =>
nodes.filter((n) => n.type === 'OutputNode')
)
@@ -150,21 +135,6 @@ describe('useSelectionState', () => {
const { hasMultipleSelection } = useSelectionState()
expect(hasMultipleSelection.value).toBe(false)
})
test('hasGroupedNodesSelection should detect a group containing nodes', () => {
const canvasStore = useCanvasStore()
const graphNode = createMockLGraphNode({ id: 2 })
const group = createMockPositionable({ id: 2000 })
Object.assign(group, {
isGroup: true,
isNode: false,
children: new Set([graphNode])
})
canvasStore.$state.selectedItems = [group]
const { hasGroupedNodesSelection } = useSelectionState()
expect(hasGroupedNodesSelection.value).toBe(true)
})
})
describe('Node Type Filtering', () => {
@@ -245,13 +215,6 @@ describe('useSelectionState', () => {
const newIsPinned = newSelectedNodes.value.some((n) => n.pinned === true)
expect(newIsPinned).toBe(false)
})
test('should compute default flags for an empty node selection', () => {
expect(useSelectionState().computeSelectionFlags()).toEqual({
collapsed: false,
pinned: false
})
})
})
describe('Node Info', () => {

View File

@@ -4,45 +4,34 @@ import { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
const mocks = vi.hoisted(() => ({
publishSubgraph: vi.fn(),
selectedItems: [] as unknown[],
getSelectedNodes: vi.fn((): unknown[] => []),
getCanvas: vi.fn(),
updateSelectedItems: vi.fn(),
revokeSubgraphPreviews: vi.fn(),
activeWorkflow: null as null | {
changeTracker?: {
captureCanvasState: () => void
}
}
selectedItems: [] as unknown[]
}))
vi.mock('@/composables/canvas/useSelectedLiteGraphItems', () => ({
useSelectedLiteGraphItems: () => ({
getSelectedNodes: mocks.getSelectedNodes
getSelectedNodes: vi.fn(() => [])
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
getCanvas: mocks.getCanvas,
getCanvas: vi.fn(),
get selectedItems() {
return mocks.selectedItems
},
updateSelectedItems: mocks.updateSelectedItems
updateSelectedItems: vi.fn()
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
get activeWorkflow() {
return mocks.activeWorkflow
}
activeWorkflow: null
})
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => ({
revokeSubgraphPreviews: mocks.revokeSubgraphPreviews
revokeSubgraphPreviews: vi.fn()
})
}))
@@ -61,36 +50,10 @@ function createRegularNode(): LGraphNode {
return new LGraphNode('testnode')
}
function createCanvas({
graph,
subgraph,
selectedItems = []
}: {
graph?: {
convertToSubgraph?: ReturnType<typeof vi.fn>
unpackSubgraph?: ReturnType<typeof vi.fn>
}
subgraph?: {
convertToSubgraph?: ReturnType<typeof vi.fn>
unpackSubgraph?: ReturnType<typeof vi.fn>
}
selectedItems?: unknown[]
} = {}) {
return {
graph,
subgraph,
selectedItems: new Set(selectedItems),
select: vi.fn()
}
}
describe('useSubgraphOperations', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.selectedItems = []
mocks.getSelectedNodes.mockReturnValue([])
mocks.getCanvas.mockReturnValue(createCanvas())
mocks.activeWorkflow = null
})
it('addSubgraphToLibrary calls publishSubgraph when single SubgraphNode selected', async () => {
@@ -140,126 +103,4 @@ describe('useSubgraphOperations', () => {
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
})
it('reports selected subgraph and selectable node state', async () => {
mocks.selectedItems = [createRegularNode()]
mocks.getSelectedNodes.mockReturnValue([])
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { isSubgraphSelected, hasSelectableNodes } = useSubgraphOperations()
expect(isSubgraphSelected()).toBe(false)
expect(hasSelectableNodes()).toBe(false)
mocks.selectedItems = [createSubgraphNode()]
mocks.getSelectedNodes.mockReturnValue([createRegularNode()])
expect(isSubgraphSelected()).toBe(true)
expect(hasSelectableNodes()).toBe(true)
})
it('converts selected items to a subgraph and captures workflow state', async () => {
const captureCanvasState = vi.fn()
const node = createSubgraphNode()
const graph = {
convertToSubgraph: vi.fn(() => ({ node })),
unpackSubgraph: vi.fn()
}
const canvas = createCanvas({
graph,
selectedItems: [createRegularNode()]
})
mocks.getCanvas.mockReturnValue(canvas)
mocks.activeWorkflow = {
changeTracker: {
captureCanvasState
}
}
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { convertToSubgraph } = useSubgraphOperations()
convertToSubgraph()
expect(graph.convertToSubgraph).toHaveBeenCalledWith(canvas.selectedItems)
expect(canvas.select).toHaveBeenCalledWith(node)
expect(mocks.updateSelectedItems).toHaveBeenCalledOnce()
expect(captureCanvasState).toHaveBeenCalledOnce()
})
it('does not select or capture when conversion has no graph or no result', async () => {
const graph = {
convertToSubgraph: vi.fn(() => null),
unpackSubgraph: vi.fn()
}
const canvas = createCanvas({ graph })
mocks.getCanvas
.mockReturnValueOnce(createCanvas())
.mockReturnValueOnce(canvas)
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { convertToSubgraph } = useSubgraphOperations()
expect(convertToSubgraph()).toBeNull()
expect(convertToSubgraph()).toBeUndefined()
expect(canvas.select).not.toHaveBeenCalled()
expect(mocks.updateSelectedItems).not.toHaveBeenCalled()
})
it('unpacks selected subgraph nodes from the active graph and revokes previews', async () => {
const captureCanvasState = vi.fn()
const subgraphNode = createSubgraphNode()
const graph = {
convertToSubgraph: vi.fn(),
unpackSubgraph: vi.fn()
}
mocks.getCanvas.mockReturnValue(
createCanvas({
subgraph: graph,
selectedItems: [subgraphNode, createRegularNode()]
})
)
mocks.activeWorkflow = {
changeTracker: {
captureCanvasState
}
}
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { unpackSubgraph } = useSubgraphOperations()
unpackSubgraph()
expect(mocks.revokeSubgraphPreviews).toHaveBeenCalledWith(subgraphNode)
expect(graph.unpackSubgraph).toHaveBeenCalledWith(subgraphNode, {
skipMissingNodes: true
})
expect(captureCanvasState).toHaveBeenCalledOnce()
})
it('does not unpack when no graph or no subgraph nodes are selected', async () => {
const graph = {
convertToSubgraph: vi.fn(),
unpackSubgraph: vi.fn()
}
mocks.getCanvas
.mockReturnValueOnce(createCanvas())
.mockReturnValueOnce(
createCanvas({ graph, selectedItems: [createRegularNode()] })
)
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { unpackSubgraph } = useSubgraphOperations()
unpackSubgraph()
unpackSubgraph()
expect(graph.unpackSubgraph).not.toHaveBeenCalled()
expect(mocks.revokeSubgraphPreviews).not.toHaveBeenCalled()
})
})

View File

@@ -1,342 +0,0 @@
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as VueUse from '@vueuse/core'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { UNASSIGNED_NODE_ID, toNodeId } from '@/types/nodeId'
type MockReroute = {
id: string
pos: [number, number]
parentId?: string | null
linkIds: Set<string>
}
type MockLink = {
id: string
origin_id: string
origin_slot: number
target_id: string
target_slot: number
}
type MockGraph = {
_nodes: LGraphNode[]
reroutes: Map<string, MockReroute>
_links: Map<string, MockLink>
onNodeAdded?: (node: LGraphNode) => void
}
type MockCanvas = {
graph?: MockGraph
setDirty: ReturnType<typeof vi.fn>
}
const mockWheneverCallbacks = vi.hoisted(() => ({
values: [] as Array<() => void>
}))
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal<typeof VueUse>()
return {
...actual,
createSharedComposable: <Args extends unknown[], Return>(
fn: (...args: Args) => Return
) => fn,
whenever: (_source: () => boolean, callback: () => void) => {
mockWheneverCallbacks.values.push(callback)
return vi.fn()
}
}
})
const mockUseGraphNodeManager = vi.hoisted(() => vi.fn())
vi.mock('@/composables/graph/useGraphNodeManager', () => ({
useGraphNodeManager: mockUseGraphNodeManager
}))
const mockShouldRenderVueNodes = vi.hoisted(() => ({ value: false }))
vi.mock('@/composables/useVueFeatureFlags', () => ({
useVueFeatureFlags: () => ({
shouldRenderVueNodes: mockShouldRenderVueNodes
})
}))
const mockCanvasStoreCanvas = vi.hoisted(() => ({
value: undefined as MockCanvas | undefined
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: mockCanvasStoreCanvas.value
})
}))
const mockCreateReroute = vi.hoisted(() => vi.fn())
const mockCreateLink = vi.hoisted(() => vi.fn())
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => ({
useLayoutMutations: () => ({
createReroute: mockCreateReroute,
createLink: mockCreateLink
})
}))
const mockInitializeFromLiteGraph = vi.hoisted(() => vi.fn())
const mockClearAllSlotLayouts = vi.hoisted(() => vi.fn())
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
initializeFromLiteGraph: mockInitializeFromLiteGraph,
clearAllSlotLayouts: mockClearAllSlotLayouts
}
}))
const mockStartSync = vi.hoisted(() => vi.fn())
const mockStopSync = vi.hoisted(() => vi.fn())
vi.mock('@/renderer/core/layout/sync/useLayoutSync', () => ({
useLayoutSync: () => ({
startSync: mockStartSync,
stopSync: mockStopSync
})
}))
const mockComfyCanvas = vi.hoisted(() => ({
value: undefined as MockCanvas | undefined
}))
vi.mock('@/scripts/app', () => ({
app: {
get canvas() {
return mockComfyCanvas.value
}
}
}))
const mockManagerCleanup = vi.hoisted(() => vi.fn())
function createNode(id: number, overrides: object = {}): LGraphNode {
return fromAny<LGraphNode, unknown>({
id: toNodeId(id),
pos: [id * 10, id * 20],
size: [100 + id, 200 + id],
flags: { collapsed: false },
arrange: vi.fn(),
...overrides
})
}
function createGraph(overrides: Partial<MockGraph> = {}): MockGraph {
return {
_nodes: [],
reroutes: new Map(),
_links: new Map(),
...overrides
}
}
async function loadLifecycle() {
vi.resetModules()
const { useVueNodeLifecycle } = await import('./useVueNodeLifecycle')
return useVueNodeLifecycle()
}
describe('useVueNodeLifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWheneverCallbacks.values = []
mockShouldRenderVueNodes.value = false
mockCanvasStoreCanvas.value = undefined
mockComfyCanvas.value = undefined
mockManagerCleanup.mockReset()
mockUseGraphNodeManager.mockReset()
mockUseGraphNodeManager.mockReturnValue({ cleanup: mockManagerCleanup })
})
it('initializes the node manager from the active graph', async () => {
const node = createNode(1)
const graph = createGraph({
_nodes: [node],
reroutes: new Map([
[
'reroute-1',
{
id: 'reroute-1',
pos: [12, 34],
parentId: null,
linkIds: new Set(['link-1'])
}
]
]),
_links: new Map([
[
'link-1',
{
id: 'link-1',
origin_id: toNodeId(1),
origin_slot: 0,
target_id: toNodeId(2),
target_slot: 1
}
],
[
'link-2',
{
id: 'link-2',
origin_id: UNASSIGNED_NODE_ID,
origin_slot: 0,
target_id: toNodeId(2),
target_slot: 1
}
],
[
'link-3',
{
id: 'link-3',
origin_id: toNodeId(1),
origin_slot: 0,
target_id: UNASSIGNED_NODE_ID,
target_slot: 1
}
]
])
})
const canvas = { graph, setDirty: vi.fn() }
mockComfyCanvas.value = canvas
mockCanvasStoreCanvas.value = canvas
mockShouldRenderVueNodes.value = true
const lifecycle = await loadLifecycle()
expect(mockUseGraphNodeManager).toHaveBeenCalledWith(graph)
expect(lifecycle.nodeManager.value).toEqual({
cleanup: mockManagerCleanup
})
expect(mockInitializeFromLiteGraph).toHaveBeenCalledWith([
{
id: toNodeId(1),
pos: [10, 20],
size: [101, 201]
}
])
expect(mockCreateReroute).toHaveBeenCalledWith(
'reroute-1',
{ x: 12, y: 34 },
undefined,
['link-1']
)
expect(mockCreateLink).toHaveBeenCalledOnce()
expect(mockCreateLink).toHaveBeenCalledWith(
'link-1',
toNodeId(1),
0,
toNodeId(2),
1
)
expect(mockStartSync).toHaveBeenCalledWith(canvas)
})
it('does not initialize without an active graph', async () => {
mockShouldRenderVueNodes.value = true
const lifecycle = await loadLifecycle()
lifecycle.initializeNodeManager()
expect(mockUseGraphNodeManager).not.toHaveBeenCalled()
expect(mockStartSync).not.toHaveBeenCalled()
})
it('stops sync and tolerates manager cleanup errors', async () => {
mockManagerCleanup.mockImplementation(() => {
throw new Error('cleanup failed')
})
mockComfyCanvas.value = {
graph: createGraph(),
setDirty: vi.fn()
}
mockShouldRenderVueNodes.value = true
const lifecycle = await loadLifecycle()
expect(() => lifecycle.disposeNodeManagerAndSyncs()).not.toThrow()
expect(mockStopSync).toHaveBeenCalled()
expect(lifecycle.nodeManager.value).toBeNull()
})
it('arranges legacy nodes when the Vue node mode is disabled', async () => {
const arrangeVisible = vi.fn()
const arrangeThrowing = vi.fn(() => {
throw new Error('not ready')
})
const graph = createGraph({
_nodes: [
createNode(1, { arrange: arrangeVisible }),
createNode(2, { flags: { collapsed: true }, arrange: vi.fn() }),
createNode(3, { arrange: arrangeThrowing })
]
})
const canvas = { graph, setDirty: vi.fn() }
mockComfyCanvas.value = canvas
mockShouldRenderVueNodes.value = true
await loadLifecycle()
mockWheneverCallbacks.values[0]()
expect(arrangeVisible).toHaveBeenCalled()
expect(arrangeThrowing).toHaveBeenCalled()
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('marks the canvas dirty when disabling without a graph', async () => {
const canvas = { setDirty: vi.fn() }
mockComfyCanvas.value = canvas
await loadLifecycle()
mockWheneverCallbacks.values[0]()
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('initializes on the first node added to an empty graph', async () => {
mockShouldRenderVueNodes.value = true
const originalOnNodeAdded = vi.fn()
const graph = createGraph({ onNodeAdded: originalOnNodeAdded })
const canvas = { graph, setDirty: vi.fn() }
const node = createNode(1)
const lifecycle = await loadLifecycle()
mockComfyCanvas.value = canvas
lifecycle.setupEmptyGraphListener()
graph.onNodeAdded?.(node)
expect(mockUseGraphNodeManager).toHaveBeenCalledWith(graph)
expect(graph.onNodeAdded).toBe(originalOnNodeAdded)
expect(originalOnNodeAdded).toHaveBeenCalledWith(node)
})
it('does not replace onNodeAdded when the empty-graph guard fails', async () => {
const originalOnNodeAdded = vi.fn()
const graph = createGraph({
_nodes: [createNode(1)],
onNodeAdded: originalOnNodeAdded
})
mockComfyCanvas.value = { graph, setDirty: vi.fn() }
mockShouldRenderVueNodes.value = true
const lifecycle = await loadLifecycle()
lifecycle.setupEmptyGraphListener()
expect(graph.onNodeAdded).toBe(originalOnNodeAdded)
})
it('cleans up the node manager on unmount', async () => {
mockComfyCanvas.value = {
graph: createGraph(),
setDirty: vi.fn()
}
mockShouldRenderVueNodes.value = true
const lifecycle = await loadLifecycle()
lifecycle.cleanup()
lifecycle.cleanup()
expect(mockManagerCleanup).toHaveBeenCalledOnce()
expect(lifecycle.nodeManager.value).toBeNull()
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -23,8 +23,8 @@ const mockStore = reactive({
gpuTexturesNeedRecreation: false,
gpuTextureWidth: 0,
gpuTextureHeight: 0,
pendingGPUMaskData: null as Uint8Array | null,
pendingGPURgbData: null as Uint8Array | null,
pendingGPUMaskData: null as null,
pendingGPURgbData: null as null,
brushSettings: {
size: 20,
hardness: 0.9,
@@ -42,9 +42,6 @@ vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
import { tgpu } from 'typegpu'
import { GPUBrushRenderer } from './gpu/GPUBrushRenderer'
import { resetDirtyRect } from './brushDrawingUtils'
import { useGPUResources } from './useGPUResources'
@@ -55,120 +52,8 @@ function setup() {
return scope.run(() => useGPUResources())!
}
class TestImageData {
data: Uint8ClampedArray
width: number
height: number
constructor(data: Uint8ClampedArray, width: number, height: number) {
this.data = data
this.width = width
this.height = height
}
}
function createMockTexture() {
return {
createView: vi.fn(() => ({})),
destroy: vi.fn()
} as unknown as GPUTexture
}
function createMockBuffer(byteLength = 16) {
return {
destroy: vi.fn(),
mapAsync: vi.fn().mockResolvedValue(undefined),
getMappedRange: vi.fn(() => new Uint8Array(byteLength).buffer),
unmap: vi.fn()
} as unknown as GPUBuffer
}
function createMockDevice() {
return {
limits: {},
queue: {
writeTexture: vi.fn(),
submit: vi.fn()
},
createTexture: vi.fn(() => createMockTexture()),
createBuffer: vi.fn(() => createMockBuffer()),
createCommandEncoder: vi.fn(() => ({
copyBufferToBuffer: vi.fn(),
finish: vi.fn(() => ({}))
}))
} as unknown as GPUDevice
}
function createMockRenderer() {
return {
destroy: vi.fn(),
prepareStroke: vi.fn(),
clearPreview: vi.fn(),
compositeStroke: vi.fn(),
prepareReadback: vi.fn(),
renderStrokeToAccumulator: vi.fn(),
blitToCanvas: vi.fn()
}
}
function mockGpuBrushRenderer(renderer: ReturnType<typeof createMockRenderer>) {
vi.mocked(GPUBrushRenderer).mockImplementation(
function GPUBrushRendererMock() {
return renderer as unknown as GPUBrushRenderer
}
)
}
function createCanvasContext(width: number, height: number) {
return {
globalCompositeOperation: 'source-over',
getImageData: vi.fn(
() =>
new ImageData(new Uint8ClampedArray(width * height * 4), width, height)
),
putImageData: vi.fn()
} as unknown as CanvasRenderingContext2D
}
function setReadyCanvases(width = 2, height = 2) {
const maskCanvas = document.createElement('canvas')
maskCanvas.width = width
maskCanvas.height = height
const rgbCanvas = document.createElement('canvas')
rgbCanvas.width = width
rgbCanvas.height = height
mockStore.maskCanvas = maskCanvas
mockStore.rgbCanvas = rgbCanvas
mockStore.maskCtx = createCanvasContext(width, height)
mockStore.rgbCtx = createCanvasContext(width, height)
}
function installGpuGlobals() {
vi.stubGlobal('GPUTextureUsage', {
TEXTURE_BINDING: 1,
STORAGE_BINDING: 2,
RENDER_ATTACHMENT: 4,
COPY_DST: 8,
COPY_SRC: 16
})
vi.stubGlobal('GPUBufferUsage', {
STORAGE: 1,
COPY_SRC: 2,
COPY_DST: 4,
MAP_READ: 8
})
vi.stubGlobal('GPUMapMode', { READ: 1 })
vi.stubGlobal('ImageData', TestImageData)
Object.defineProperty(navigator, 'gpu', {
value: { getPreferredCanvasFormat: vi.fn(() => 'rgba8unorm') },
configurable: true
})
}
beforeEach(() => {
vi.clearAllMocks()
installGpuGlobals()
mockStore.tgpuRoot = null
mockStore.maskCanvas = null
mockStore.rgbCanvas = null
@@ -177,28 +62,11 @@ beforeEach(() => {
mockStore.clearTrigger = 0
mockStore.canvasHistory.currentStateIndex = 0
mockStore.gpuTexturesNeedRecreation = false
mockStore.gpuTextureWidth = 0
mockStore.gpuTextureHeight = 0
mockStore.pendingGPUMaskData = null
mockStore.pendingGPURgbData = null
mockStore.activeLayer = 'mask'
mockStore.currentTool = 'pen'
mockStore.maskColor = { r: 0, g: 0, b: 0 }
mockStore.rgbColor = '#FF0000'
mockStore.brushSettings = {
size: 20,
hardness: 0.9,
opacity: 1,
stepSize: 5,
type: 'arc'
}
vi.mocked(tgpu.init).mockRejectedValue(new Error('WebGPU not supported'))
})
afterEach(() => {
scope?.stop()
scope = null
vi.unstubAllGlobals()
})
describe('initial reactive state', () => {
@@ -263,34 +131,6 @@ describe('initGPUResources', () => {
await initGPUResources()
expect(hasRenderer.value).toBe(false)
})
it('handles non-error TypeGPU initialisation failures', async () => {
vi.mocked(tgpu.init).mockRejectedValueOnce('WebGPU unavailable')
const { initGPUResources, hasRenderer } = setup()
await initGPUResources()
expect(hasRenderer.value).toBe(false)
})
it('initializes renderer when a root and canvas contexts are ready', async () => {
const device = createMockDevice()
const renderer = createMockRenderer()
mockStore.tgpuRoot = {
device,
destroy: vi.fn()
}
setReadyCanvases()
mockGpuBrushRenderer(renderer)
const { initGPUResources, hasRenderer } = setup()
await initGPUResources()
expect(hasRenderer.value).toBe(true)
expect(device.createTexture).toHaveBeenCalledTimes(2)
expect(device.queue.writeTexture).toHaveBeenCalledTimes(2)
expect(GPUBrushRenderer).toHaveBeenCalledWith(device, 'rgba8unorm')
})
})
describe('copyGpuToCanvas', () => {
@@ -334,18 +174,6 @@ describe('initGPUResources with pre-existing tgpuRoot', () => {
await initGPUResources()
expect(hasRenderer.value).toBe(false)
})
it('texture recreation watcher returns early when mask canvas is missing', async () => {
const device = createMockDevice()
const { initGPUResources } = setup()
mockStore.tgpuRoot = { device, destroy: vi.fn() }
await initGPUResources()
mockStore.gpuTexturesNeedRecreation = true
await nextTick()
expect(device.createTexture).not.toHaveBeenCalled()
})
})
describe('initPreviewCanvas', () => {
@@ -354,47 +182,6 @@ describe('initPreviewCanvas', () => {
const canvas = document.createElement('canvas')
expect(() => initPreviewCanvas(canvas)).not.toThrow()
})
it('returns early when a WebGPU canvas context is unavailable', async () => {
const device = createMockDevice()
mockStore.tgpuRoot = { device, destroy: vi.fn() }
setReadyCanvases()
mockGpuBrushRenderer(createMockRenderer())
const { initGPUResources, initPreviewCanvas, previewCanvas } = setup()
await initGPUResources()
const canvas = document.createElement('canvas')
Object.defineProperty(canvas, 'getContext', { value: vi.fn(() => null) })
initPreviewCanvas(canvas)
expect(previewCanvas.value).toBeNull()
})
it('stores the preview canvas when a WebGPU context is available', async () => {
const device = createMockDevice()
const renderer = createMockRenderer()
const previewContext = { configure: vi.fn() }
mockStore.tgpuRoot = { device, destroy: vi.fn() }
setReadyCanvases()
mockGpuBrushRenderer(renderer)
const { initGPUResources, initPreviewCanvas, previewCanvas } = setup()
await initGPUResources()
const canvas = document.createElement('canvas')
Object.defineProperty(canvas, 'getContext', {
value: vi.fn(() => previewContext)
})
initPreviewCanvas(canvas)
expect(previewContext.configure).toHaveBeenCalledWith({
device,
format: 'rgba8unorm',
alphaMode: 'premultiplied'
})
expect(previewCanvas.value).toBe(canvas)
})
})
describe('gpuDrawPoint', () => {
@@ -402,70 +189,4 @@ describe('gpuDrawPoint', () => {
const { gpuDrawPoint } = setup()
await expect(gpuDrawPoint({ x: 10, y: 20 })).resolves.toBeUndefined()
})
it('delegates renderer operations when GPU resources are initialized', async () => {
const device = createMockDevice()
const renderer = createMockRenderer()
const previewContext = { configure: vi.fn() }
mockStore.tgpuRoot = { device, destroy: vi.fn() }
setReadyCanvases()
mockGpuBrushRenderer(renderer)
const resources = setup()
await resources.initGPUResources()
const canvas = document.createElement('canvas')
Object.defineProperty(canvas, 'getContext', {
value: vi.fn(() => previewContext)
})
resources.initPreviewCanvas(canvas)
resources.prepareStroke()
resources.clearPreview()
resources.compositeStroke(false, false)
resources.gpuRender([{ x: 1, y: 1 }])
await resources.gpuDrawPoint({ x: 1, y: 1 })
expect(renderer.prepareStroke).toHaveBeenCalledWith(2, 2)
expect(renderer.clearPreview).toHaveBeenCalledWith(previewContext)
expect(renderer.compositeStroke).toHaveBeenCalled()
expect(renderer.renderStrokeToAccumulator).toHaveBeenCalled()
expect(renderer.blitToCanvas).toHaveBeenCalled()
})
it('copies initialized GPU readback data to canvases', async () => {
const device = createMockDevice()
const renderer = createMockRenderer()
mockStore.tgpuRoot = { device, destroy: vi.fn() }
setReadyCanvases()
mockGpuBrushRenderer(renderer)
const resources = setup()
await resources.initGPUResources()
const result = await resources.copyGpuToCanvas()
expect(result.maskData.width).toBe(2)
expect(result.rgbData.height).toBe(2)
expect(renderer.prepareReadback).toHaveBeenCalledTimes(2)
expect(mockStore.maskCtx?.putImageData).toHaveBeenCalled()
expect(mockStore.rgbCtx?.putImageData).toHaveBeenCalled()
})
it('destroys initialized GPU resources and root state', async () => {
const device = createMockDevice()
const renderer = createMockRenderer()
const root = { device, destroy: vi.fn() }
mockStore.tgpuRoot = root
setReadyCanvases()
mockGpuBrushRenderer(renderer)
const { initGPUResources, destroy, hasRenderer } = setup()
await initGPUResources()
destroy()
expect(renderer.destroy).toHaveBeenCalled()
expect(root.destroy).toHaveBeenCalled()
expect(mockStore.tgpuRoot).toBeNull()
expect(hasRenderer.value).toBe(false)
})
})

View File

@@ -1,408 +0,0 @@
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
extractWidgetStringValue,
useMaskEditorLoader
} from '@/composables/maskeditor/useMaskEditorLoader'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { api } from '@/scripts/api'
interface MockInputData {
baseLayer: { url: string }
maskLayer: { url: string }
paintLayer?: { url: string }
sourceRef: { filename: string; subfolder?: string; type?: string }
nodeId: unknown
}
const mockDataStore = vi.hoisted(() => ({
inputData: undefined as unknown,
sourceNode: undefined as unknown,
setLoading: vi.fn()
}))
const mockNodeOutputStore = vi.hoisted(() => ({
getNodeOutputs: vi.fn()
}))
const mockCloudState = vi.hoisted(() => ({
isCloud: false
}))
vi.mock('@/stores/maskEditorDataStore', () => ({
useMaskEditorDataStore: () => mockDataStore
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => mockNodeOutputStore
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockCloudState.isCloud
}
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: vi.fn((path: string) => `http://comfy.test${path}`),
fetchApi: vi.fn()
}
}))
vi.mock('@/scripts/app', () => ({
app: {
getPreviewFormatParam: vi.fn(() => '&preview=png'),
getRandParam: vi.fn(() => '&rand=1')
}
}))
function createMockImageClass(handler: 'onload' | 'onerror') {
return class {
crossOrigin = ''
onerror: (() => void) | null = null
onload: (() => void) | null = null
private imageSrc = ''
get src() {
return this.imageSrc
}
set src(value: string) {
this.imageSrc = value
queueMicrotask(() => this[handler]?.())
}
}
}
const MockImage = createMockImageClass('onload')
function makeNode(overrides: object = {}): LGraphNode {
return fromAny({
id: 42,
imgs: [{ src: 'http://images.test/render.png?filename=render.png' }],
imageIndex: 0,
...overrides
})
}
function getInputData(): MockInputData {
return mockDataStore.inputData as MockInputData
}
describe('extractWidgetStringValue', () => {
it('extracts strings and filename objects', () => {
expect(extractWidgetStringValue('image.png')).toBe('image.png')
expect(extractWidgetStringValue({ filename: 'object.png' })).toBe(
'object.png'
)
expect(extractWidgetStringValue({ filename: 123 })).toBeUndefined()
expect(extractWidgetStringValue(null)).toBeUndefined()
})
})
describe('useMaskEditorLoader', () => {
beforeEach(() => {
vi.stubGlobal('Image', MockImage)
vi.spyOn(console, 'error').mockImplementation(() => undefined)
vi.mocked(api.apiURL).mockClear()
vi.mocked(api.fetchApi).mockReset()
mockDataStore.inputData = undefined
mockDataStore.sourceNode = undefined
mockDataStore.setLoading.mockClear()
mockNodeOutputStore.getNodeOutputs.mockReset()
mockCloudState.isCloud = false
})
it('loads base and mask layers from a node image reference', async () => {
const node = makeNode({
images: [
{
filename: 'node-output.png',
subfolder: 'outputs',
type: 'output'
}
]
})
await useMaskEditorLoader().loadFromNode(node)
expect(mockDataStore.setLoading).toHaveBeenNthCalledWith(1, true)
expect(mockDataStore.setLoading).toHaveBeenLastCalledWith(false)
expect(mockDataStore.sourceNode).toBe(node)
expect(mockDataStore.inputData).toMatchObject({
nodeId: 42,
sourceRef: {
filename: 'node-output.png',
subfolder: 'outputs',
type: 'output'
},
paintLayer: undefined
})
expect(getInputData().baseLayer.url).toContain('channel=rgb')
expect(getInputData().maskLayer.url).toContain('channel=a')
})
it('uses a concrete image widget value instead of a stale node image', async () => {
await useMaskEditorLoader().loadFromNode(
makeNode({
images: [{ filename: 'stale.png', type: 'output', subfolder: '' }],
widgets: [
{
name: 'image',
value: 'clipspace/current.png [input]'
}
]
})
)
expect(getInputData().sourceRef).toMatchObject({
filename: 'current.png',
subfolder: 'clipspace',
type: 'input'
})
expect(getInputData().baseLayer.url).toContain('filename=current.png')
})
it('keeps internal widget references from replacing the node image', async () => {
await useMaskEditorLoader().loadFromNode(
makeNode({
images: [{ filename: 'real-output.png', type: 'output' }],
widgets: [{ name: 'image', value: '$35-0' }]
})
)
expect(getInputData().sourceRef.filename).toBe('real-output.png')
})
it('loads image references from node output store data', async () => {
mockNodeOutputStore.getNodeOutputs.mockReturnValue({
images: [
{
filename: 'store-output.png',
subfolder: 'store',
type: 'temp'
}
]
})
await useMaskEditorLoader().loadFromNode(
makeNode({
images: undefined,
imgs: [{ src: 'data:image/png;base64,abc' }]
})
)
expect(getInputData().sourceRef).toMatchObject({
filename: 'store-output.png',
subfolder: 'store',
type: 'temp'
})
})
it('uses the current non-data preview image when no image reference exists', async () => {
await useMaskEditorLoader().loadFromNode(
makeNode({
images: undefined,
imageIndex: 1,
imgs: [
{ src: 'http://images.test/first.png?filename=first.png' },
{ src: '/view?filename=second.png&type=input' }
]
})
)
expect(getInputData().sourceRef).toMatchObject({
filename: 'second.png',
type: 'input'
})
})
it('uses cloud mask layer metadata when available', async () => {
mockCloudState.isCloud = true
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
painted_masked: 'painted-masked.png',
painted: 'painted.png',
paint: 'paint.png'
})
} as unknown as Response)
await useMaskEditorLoader().loadFromNode(
makeNode({
images: [{ filename: 'cloud.png', type: 'output' }]
})
)
expect(api.fetchApi).toHaveBeenCalledWith(
'/files/mask-layers?filename=cloud.png'
)
expect(getInputData().sourceRef.filename).toBe('painted-masked.png')
expect(getInputData().paintLayer?.url).toContain('filename=paint.png')
})
it('loads clipspace layer filenames from painted-masked images', async () => {
await useMaskEditorLoader().loadFromNode(
makeNode({
images: [
{
filename: 'clipspace-painted-masked-123.png',
subfolder: 'clipspace',
type: 'input'
}
]
})
)
expect(getInputData().sourceRef).toMatchObject({
filename: 'clipspace-mask-123.png',
subfolder: 'clipspace',
type: 'input'
})
expect(getInputData().paintLayer?.url).toContain(
'filename=clipspace-paint-123.png'
)
})
it('uses painted cloud metadata when painted-masked metadata is absent', async () => {
mockCloudState.isCloud = true
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
painted: 'painted-only.png'
})
} as unknown as Response)
await useMaskEditorLoader().loadFromNode(
makeNode({
images: [{ filename: 'cloud.png', type: 'output' }]
})
)
expect(getInputData().sourceRef.filename).toBe('painted-only.png')
expect(getInputData().paintLayer).toBeUndefined()
})
it('keeps the node image when cloud mask metadata is unavailable', async () => {
mockCloudState.isCloud = true
vi.mocked(api.fetchApi).mockResolvedValue({
ok: false
} as Response)
await useMaskEditorLoader().loadFromNode(
makeNode({
images: [{ filename: 'cloud.png', type: 'output' }]
})
)
expect(getInputData().sourceRef.filename).toBe('cloud.png')
expect(getInputData().paintLayer).toBeUndefined()
})
it('keeps the node image when cloud mask metadata lookup rejects', async () => {
mockCloudState.isCloud = true
vi.mocked(api.fetchApi).mockRejectedValue(new Error('offline'))
await useMaskEditorLoader().loadFromNode(
makeNode({
images: [{ filename: 'cloud.png', type: 'output' }]
})
)
expect(getInputData().sourceRef.filename).toBe('cloud.png')
})
it('loads widget filenames without explicit folder metadata as inputs', async () => {
await useMaskEditorLoader().loadFromNode(
makeNode({
images: [{ filename: 'stale.png', type: 'output' }],
widgets: [
{
name: 'image',
value: 'plain.png'
}
]
})
)
expect(getInputData().sourceRef).toMatchObject({
filename: 'plain.png',
type: 'input'
})
expect(getInputData().sourceRef.subfolder).toBeUndefined()
})
it('surfaces validation failures and clears loading state', async () => {
await expect(
useMaskEditorLoader().loadFromNode(makeNode({ imgs: [], images: [] }))
).rejects.toThrow('Node has no images')
expect(mockDataStore.setLoading).toHaveBeenNthCalledWith(1, true)
expect(mockDataStore.setLoading).toHaveBeenLastCalledWith(
false,
'Node has no images'
)
})
it('surfaces null node validation failures', async () => {
await expect(
useMaskEditorLoader().loadFromNode(null as unknown as LGraphNode)
).rejects.toThrow('Node is null or undefined')
})
it('surfaces missing output filenames', async () => {
mockNodeOutputStore.getNodeOutputs.mockReturnValue({
images: [
{
filename: '',
type: 'output'
}
]
})
await expect(
useMaskEditorLoader().loadFromNode(
makeNode({
images: undefined,
imgs: [{ src: 'data:image/png;base64,abc' }]
})
)
).rejects.toThrow('nodeOutputStore image missing filename')
})
it('rejects data previews without output metadata', async () => {
await expect(
useMaskEditorLoader().loadFromNode(
makeNode({
images: undefined,
imgs: [{ src: 'data:image/png;base64,abc' }]
})
)
).rejects.toThrow('Unable to get image URL from node')
})
it('rejects preview URLs without filename metadata', async () => {
await expect(
useMaskEditorLoader().loadFromNode(
makeNode({
images: undefined,
imgs: [{ src: '/view?type=input' }]
})
)
).rejects.toThrow('Invalid image URL: /view?type=input')
})
it('propagates image load failures', async () => {
vi.stubGlobal('Image', createMockImageClass('onerror'))
await expect(
useMaskEditorLoader().loadFromNode(
makeNode({
images: [{ filename: 'broken.png', type: 'output' }]
})
)
).rejects.toThrow('Failed to load image:')
})
})

View File

@@ -201,108 +201,4 @@ describe('useMaskEditorSaver', () => {
expect(body.get('type')).toBe('input')
expect(body.get('subfolder')).toBeNull()
})
it('throws before saving when the source node is missing', async () => {
mockDataStore.sourceNode = null
const { save } = useMaskEditorSaver()
await expect(save()).rejects.toThrow('No source node or input data')
expect(api.fetchApi).not.toHaveBeenCalled()
})
it('throws before saving when the input data is missing', async () => {
mockDataStore.inputData = null
const { save } = useMaskEditorSaver()
await expect(save()).rejects.toThrow('No source node or input data')
expect(api.fetchApi).not.toHaveBeenCalled()
})
it('fails when canvases are not initialized', async () => {
mockEditorStore.maskCanvas = null
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { save } = useMaskEditorSaver()
await expect(save()).rejects.toThrow('Canvas not initialized')
expect(errorSpy).toHaveBeenCalledWith(
'[MaskEditorSaver] Save failed:',
expect.any(Error)
)
})
it('reports upload failures with the response body', async () => {
vi.mocked(api.fetchApi).mockResolvedValue({
ok: false,
status: 413,
text: () => Promise.resolve('too large')
} as Response)
vi.spyOn(console, 'error').mockImplementation(() => {})
const { save } = useMaskEditorSaver()
await expect(save()).rejects.toThrow(
/Failed to upload clipspace-mask-.*: too large/
)
})
it('reports upload failures when the response body cannot be read', async () => {
vi.mocked(api.fetchApi).mockResolvedValue({
ok: false,
status: 500,
text: () => Promise.reject(new Error('body unavailable'))
} as Response)
vi.spyOn(console, 'error').mockImplementation(() => {})
const { save } = useMaskEditorSaver()
await expect(save()).rejects.toThrow(/Failed to upload .+ \(500\)/)
})
it('reports invalid upload JSON responses', async () => {
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
json: () => Promise.reject(new Error('bad json'))
} as Response)
vi.spyOn(console, 'error').mockImplementation(() => {})
const { save } = useMaskEditorSaver()
await expect(save()).rejects.toThrow(/Invalid upload response.*bad json/)
})
it('reports upload responses without a name', async () => {
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ subfolder: 'clipspace', type: 'input' })
} as Response)
vi.spyOn(console, 'error').mockImplementation(() => {})
const { save } = useMaskEditorSaver()
await expect(save()).rejects.toThrow(
"Upload response missing 'name' for clipspace-mask-"
)
})
it('defaults missing upload ref fields and skips missing image widget state', async () => {
mockNode.widgets = [fromAny({ name: 'other', value: 'unchanged' })]
mockNode.widgets_values = ['unchanged']
mockNode.properties = fromAny(undefined)
mockNode.graph = fromAny(undefined)
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ name: 'uploaded.png' })
} as Response)
const { save } = useMaskEditorSaver()
await save()
expect(mockNode.images).toEqual([
{ filename: 'uploaded.png', subfolder: '', type: 'input' }
])
expect(mockNode.widgets_values).toEqual(['unchanged'])
})
})

View File

@@ -181,50 +181,6 @@ describe('usePanAndZoom', () => {
expect(rgbCanvas.width).toBe(800)
expect(rgbCanvas.height).toBe(600)
})
it('returns before publishing pan when the canvas container is unavailable', async () => {
const pz = usePanAndZoom()
await pz.initializeCanvasPanZoom(
createMockImage(800, 600),
createMockElement()
)
expect(mockStore.setPanOffset).not.toHaveBeenCalled()
expect(mockStore.setZoomRatio).not.toHaveBeenCalled()
})
it('keeps rgbCanvas dimensions when they already match the image', async () => {
const pz = usePanAndZoom()
const rgbCanvas = createMockCanvas(800, 600)
mockStore.canvasContainer = createMockElement() as unknown as HTMLElement
mockStore.rgbCanvas = rgbCanvas
await pz.initializeCanvasPanZoom(
createMockImage(800, 600),
createMockElement()
)
expect(rgbCanvas.width).toBe(800)
expect(rgbCanvas.height).toBe(600)
expect(rgbCanvas.style.width).not.toBe('')
})
it('can be initialized again without replacing the current image reference', async () => {
const pz = usePanAndZoom()
mockStore.canvasContainer = createMockElement() as unknown as HTMLElement
await pz.initializeCanvasPanZoom(
createMockImage(800, 600),
createMockElement()
)
await pz.initializeCanvasPanZoom(
createMockImage(400, 300),
createMockElement()
)
expect(mockStore.setZoomRatio).toHaveBeenCalledTimes(2)
})
})
describe('handlePanStart / handlePanMove', () => {
@@ -389,13 +345,6 @@ describe('usePanAndZoom', () => {
expect(mockStore.brushVisible).toBe(false)
})
it('accepts touch starts without active touches', () => {
const pz = usePanAndZoom()
pz.handleTouchStart(createTouchEvent(createTouchList()))
expect(mockStore.brushVisible).toBe(false)
expect(mockStore.canvasHistory.undo).not.toHaveBeenCalled()
})
it('ignores touch when pen pointer is active', () => {
const pz = usePanAndZoom()
pz.addPenPointerId(1)
@@ -452,49 +401,6 @@ describe('usePanAndZoom', () => {
expect(mockStore.setZoomRatio).toHaveBeenCalled()
})
it('returns from pinch zoom when the mask canvas is unavailable', async () => {
const pz = usePanAndZoom()
mockStore.maskCanvas = null
pz.handleTouchStart(
createTouchEvent(
createTouchList({ x: 200, y: 300 }, { x: 400, y: 300 })
)
)
await pz.handleTouchMove(
createTouchEvent(
createTouchList({ x: 150, y: 300 }, { x: 450, y: 300 })
)
)
expect(mockStore.setZoomRatio).not.toHaveBeenCalled()
})
it('uses the cached mask canvas for consecutive pinch moves', async () => {
const { pz } = await initComposable()
pz.handleTouchStart(
createTouchEvent(
createTouchList({ x: 200, y: 300 }, { x: 400, y: 300 })
)
)
await pz.handleTouchMove(
createTouchEvent(
createTouchList({ x: 150, y: 300 }, { x: 450, y: 300 })
)
)
vi.clearAllMocks()
await pz.handleTouchMove(
createTouchEvent(
createTouchList({ x: 140, y: 300 }, { x: 460, y: 300 })
)
)
expect(mockStore.setZoomRatio).toHaveBeenCalled()
})
it('touch move is ignored when pen is active', async () => {
const pz = usePanAndZoom()
pz.addPenPointerId(1)
@@ -512,26 +418,6 @@ describe('usePanAndZoom', () => {
pz.handleTouchEnd(event)
expect(event.preventDefault).toHaveBeenCalled()
})
it('stops pinch zooming when all touches end', async () => {
const { pz } = await initComposable()
pz.handleTouchStart(
createTouchEvent(
createTouchList({ x: 200, y: 300 }, { x: 400, y: 300 })
)
)
pz.handleTouchEnd(createTouchEvent(createTouchList()))
vi.clearAllMocks()
await pz.handleTouchMove(
createTouchEvent(
createTouchList({ x: 150, y: 300 }, { x: 450, y: 300 })
)
)
expect(mockStore.setZoomRatio).not.toHaveBeenCalled()
})
})
describe('pen pointer management', () => {

View File

@@ -73,45 +73,4 @@ describe('useNodeAnimatedImage', () => {
expect(canvasInteractionsMock.handlePointerDown).not.toHaveBeenCalled()
expect(canvasInteractionsMock.forwardEventToCanvas).not.toHaveBeenCalled()
})
it('does nothing when a node has no images or widgets', () => {
const { showAnimatedPreview, removeAnimatedPreview } =
useNodeAnimatedImage()
const noImageNode = createMockMediaNode({ imgs: [] })
const noWidgetNode = Object.assign(
createMockMediaNode({ imgs: [document.createElement('img')] }),
{ widgets: undefined }
)
showAnimatedPreview(noImageNode)
showAnimatedPreview(noWidgetNode)
removeAnimatedPreview(noWidgetNode)
expect(noImageNode.widgets).toHaveLength(0)
})
it('replaces the image in an existing preview widget', () => {
const { node, showAnimatedPreview } = setup()
const firstWidget = node.widgets[0]
const nextImage = document.createElement('img')
node.imgs = [nextImage]
showAnimatedPreview(node)
expect(node.widgets).toHaveLength(1)
expect(node.widgets[0]).toBe(firstWidget)
expect(firstWidget.element.firstChild).toBe(nextImage)
})
it('leaves an existing non-DOM preview widget untouched', () => {
const node = createMockMediaNode({ imgs: [document.createElement('img')] })
node.widgets.push({
name: '$$comfy_animation_preview',
element: undefined as unknown as HTMLElement
})
useNodeAnimatedImage().showAnimatedPreview(node)
expect(node.widgets).toHaveLength(1)
})
})

View File

@@ -1,430 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick } from 'vue'
import type { App as VueApp } from 'vue'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { BadgePosition, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import type { ComfyExtension } from '@/types/comfy'
import { toNodeId } from '@/types/nodeId'
import { NodeBadgeMode } from '@/types/nodeSource'
const {
settings,
appState,
extensionState,
nodeDefState,
pricingState,
setDirtyMock,
addEventListenerMock,
registerExtensionMock,
getCreditsBadgeMock,
updateSubgraphCreditsMock,
getNodePricingConfigMock,
getNodeDisplayPriceMock,
getRelevantWidgetNamesMock,
triggerPriceRecalculationMock,
useComputedWithWidgetWatchMock
} = vi.hoisted(() => ({
settings: {} as Record<string, unknown>,
appState: {
graph: {
nodes: [] as unknown[]
}
},
extensionState: {
installed: false,
registered: undefined as ComfyExtension | undefined
},
nodeDefState: {
value: null as Record<string, unknown> | null
},
pricingState: {
revision: { value: 0 },
config: undefined as
| {
depends_on?: {
widgets?: string[]
inputs?: string[]
input_groups?: string[]
}
}
| undefined,
label: '1 credit'
},
setDirtyMock: vi.fn(),
addEventListenerMock: vi.fn(),
registerExtensionMock: vi.fn((extension: ComfyExtension) => {
extensionState.registered = extension
}),
getCreditsBadgeMock: vi.fn((text: string) => ({ text })),
updateSubgraphCreditsMock: vi.fn(),
getNodePricingConfigMock: vi.fn(() => pricingState.config),
getNodeDisplayPriceMock: vi.fn(() => pricingState.label),
getRelevantWidgetNamesMock: vi.fn(() => ['seed']),
triggerPriceRecalculationMock: vi.fn(),
useComputedWithWidgetWatchMock: vi.fn(() => vi.fn())
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
setDirty: setDirtyMock,
canvas: {
addEventListener: addEventListenerMock
},
graph: appState.graph
}
}
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) => settings[key]
})
}))
vi.mock('@/stores/extensionStore', () => ({
useExtensionStore: () => ({
isExtensionInstalled: () => extensionState.installed,
registerExtension: registerExtensionMock
})
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({
fromLGraphNode: () => nodeDefState.value
})
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: () => ({
completedActivePalette: {
colors: {
litegraph_base: {
BADGE_FG_COLOR: '#fff',
BADGE_BG_COLOR: '#000'
}
}
}
})
}))
vi.mock('@/composables/node/useNodePricing', async () => {
const { ref } = await import('vue')
const pricingRevision = ref(pricingState.revision.value)
Object.defineProperty(pricingState.revision, 'value', {
get: () => pricingRevision.value,
set: (value: number) => {
pricingRevision.value = value
}
})
return {
useNodePricing: () => ({
pricingRevision,
getNodePricingConfig: getNodePricingConfigMock,
getNodeDisplayPrice: getNodeDisplayPriceMock,
getRelevantWidgetNames: getRelevantWidgetNamesMock,
triggerPriceRecalculation: triggerPriceRecalculationMock
})
}
})
vi.mock('@/composables/node/usePriceBadge', () => ({
usePriceBadge: () => ({
getCreditsBadge: getCreditsBadgeMock,
updateSubgraphCredits: updateSubgraphCreditsMock
})
}))
vi.mock('@/composables/node/useWatchWidget', () => ({
useComputedWithWidgetWatch: useComputedWithWidgetWatchMock
}))
class ApiNode extends LGraphNode {
static override nodeData = { name: 'ApiNode', api_node: true }
}
function mountBadge(): VueApp {
const app = createApp(
defineComponent({
setup() {
useNodeBadge()
return () => h('div')
}
})
)
app.mount(document.createElement('div'))
return app
}
function registeredExtension(): ComfyExtension {
if (!extensionState.registered)
throw new Error('Missing registered extension')
return extensionState.registered
}
function comfyApp(): Parameters<NonNullable<ComfyExtension['init']>>[0] {
return {} as Parameters<NonNullable<ComfyExtension['init']>>[0]
}
function callNodeCreated(node: LGraphNode) {
registeredExtension().nodeCreated?.(node, comfyApp())
}
function inputSlot(name: string) {
return new LGraphNode('slot').addInput(name, '*')
}
function defaultSettings() {
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.None
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.None
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.None
settings['Comfy.NodeBadge.ShowApiPricing'] = false
}
describe('useNodeBadge', () => {
let mountedApp: VueApp | undefined
beforeEach(() => {
defaultSettings()
extensionState.installed = false
extensionState.registered = undefined
appState.graph.nodes = []
nodeDefState.value = null
pricingState.revision.value = 0
pricingState.config = undefined
pricingState.label = '1 credit'
setDirtyMock.mockClear()
addEventListenerMock.mockClear()
registerExtensionMock.mockClear()
getCreditsBadgeMock.mockClear()
updateSubgraphCreditsMock.mockClear()
getNodePricingConfigMock.mockClear()
getNodeDisplayPriceMock.mockClear()
getRelevantWidgetNamesMock.mockClear()
triggerPriceRecalculationMock.mockClear()
useComputedWithWidgetWatchMock.mockClear()
})
afterEach(() => {
mountedApp?.unmount()
mountedApp = undefined
})
it('does not register the badge extension twice', async () => {
extensionState.installed = true
mountedApp = mountBadge()
await nextTick()
expect(registerExtensionMock).not.toHaveBeenCalled()
})
it('adds the configured node identity badge', async () => {
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] =
NodeBadgeMode.HideBuiltIn
nodeDefState.value = {
isCoreNode: false,
nodeLifeCycleBadgeText: 'Beta',
nodeSource: { badgeText: 'Pack' }
}
const node = new LGraphNode('Test')
node.id = toNodeId('7')
mountedApp = mountBadge()
await nextTick()
callNodeCreated(node)
const badge = node.badges[0] as () => LGraphBadge
expect(node.badgePosition).toBe(BadgePosition.TopRight)
expect(badge().text).toBe('#7 Beta Pack')
})
it('hides built-in badge text when the mode excludes core nodes', async () => {
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.HideBuiltIn
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] =
NodeBadgeMode.HideBuiltIn
nodeDefState.value = {
isCoreNode: true,
nodeLifeCycleBadgeText: 'Core',
nodeSource: { badgeText: 'Built-in' }
}
const node = new LGraphNode('Core')
node.id = toNodeId('11')
mountedApp = mountBadge()
await nextTick()
callNodeCreated(node)
const badge = node.badges[0] as () => LGraphBadge
expect(badge().text).toBe('#11')
})
it('keeps optional node definition badge text empty', async () => {
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.ShowAll
nodeDefState.value = null
const node = new LGraphNode('NoDef')
node.id = toNodeId('13')
mountedApp = mountBadge()
await nextTick()
callNodeCreated(node)
const badge = node.badges[0] as () => LGraphBadge
expect(badge().text).toBe('#13')
})
it('marks the canvas dirty when pricing changes while pricing badges are visible', async () => {
settings['Comfy.NodeBadge.ShowApiPricing'] = true
mountedApp = mountBadge()
await nextTick()
pricingState.revision.value++
await nextTick()
expect(setDirtyMock).toHaveBeenCalledWith(true, true)
})
it('does not add API pricing badges when the pricing setting is disabled', async () => {
settings['Comfy.NodeBadge.ShowApiPricing'] = false
const node = new ApiNode('API')
mountedApp = mountBadge()
await nextTick()
callNodeCreated(node)
expect(node.badges).toHaveLength(1)
expect(getCreditsBadgeMock).not.toHaveBeenCalled()
})
it('adds static API pricing badges without widget watchers', async () => {
settings['Comfy.NodeBadge.ShowApiPricing'] = true
pricingState.config = undefined
const node = new ApiNode('API')
mountedApp = mountBadge()
await nextTick()
callNodeCreated(node)
expect(node.badges).toHaveLength(2)
expect(useComputedWithWidgetWatchMock).not.toHaveBeenCalled()
expect(getCreditsBadgeMock).toHaveBeenCalledWith('1 credit')
})
it('adds dynamic widget pricing without connection hooks when no inputs matter', async () => {
settings['Comfy.NodeBadge.ShowApiPricing'] = true
pricingState.config = {
depends_on: {
widgets: ['seed']
}
}
const node = new ApiNode('API')
const originalOnConnectionsChange = node.onConnectionsChange
mountedApp = mountBadge()
await nextTick()
callNodeCreated(node)
expect(useComputedWithWidgetWatchMock).toHaveBeenCalled()
expect(node.onConnectionsChange).toBe(originalOnConnectionsChange)
})
it('adds dynamic API pricing badges and refreshes relevant input changes', async () => {
settings['Comfy.NodeBadge.ShowApiPricing'] = true
pricingState.config = {
depends_on: {
widgets: ['seed'],
inputs: ['image'],
input_groups: ['lora']
}
}
const originalOnConnectionsChange = vi.fn()
const node = new ApiNode('API')
node.onConnectionsChange = originalOnConnectionsChange
mountedApp = mountBadge()
await nextTick()
callNodeCreated(node)
expect(useComputedWithWidgetWatchMock).toHaveBeenCalledWith(node, {
widgetNames: ['seed'],
triggerCanvasRedraw: true
})
expect(getCreditsBadgeMock).toHaveBeenCalledWith('1 credit')
const priceBadge = node.badges[1] as () => { text: string }
expect(priceBadge().text).toBe('1 credit')
pricingState.label = '2 credits'
expect(priceBadge().text).toBe('2 credits')
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('image'))
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('lora.0'))
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('clip'))
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot(''))
expect(originalOnConnectionsChange).toHaveBeenCalledTimes(4)
expect(triggerPriceRecalculationMock).toHaveBeenCalledTimes(2)
expect(triggerPriceRecalculationMock).toHaveBeenCalledWith(node)
})
it('refreshes dynamic pricing inputs without an existing connection hook', async () => {
settings['Comfy.NodeBadge.ShowApiPricing'] = true
pricingState.config = {
depends_on: {
inputs: ['image']
}
}
const node = new ApiNode('API')
mountedApp = mountBadge()
await nextTick()
callNodeCreated(node)
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('image'))
expect(triggerPriceRecalculationMock).toHaveBeenCalledWith(node)
})
it('updates subgraph credit badges from registered extension hooks', async () => {
const nodes = [new LGraphNode('one'), new LGraphNode('two')]
appState.graph.nodes = nodes
mountedApp = mountBadge()
await nextTick()
await registeredExtension().init?.(comfyApp())
await registeredExtension().afterConfigureGraph?.([], comfyApp())
const setGraphHandler = addEventListenerMock.mock.calls.find(
([event]) => event === 'litegraph:set-graph'
)?.[1]
const convertedHandler = addEventListenerMock.mock.calls.find(
([event]) => event === 'subgraph-converted'
)?.[1]
setGraphHandler?.()
convertedHandler?.({ detail: { subgraphNode: nodes[0] } })
expect(updateSubgraphCreditsMock).toHaveBeenCalledWith(nodes[0])
expect(updateSubgraphCreditsMock).toHaveBeenCalledWith(nodes[1])
})
it('handles empty graph nodes during registered extension hooks', async () => {
appState.graph.nodes = undefined as unknown as LGraphNode[]
mountedApp = mountBadge()
await nextTick()
await registeredExtension().init?.(comfyApp())
await registeredExtension().afterConfigureGraph?.([], comfyApp())
const setGraphHandler = addEventListenerMock.mock.calls.find(
([event]) => event === 'litegraph:set-graph'
)?.[1]
setGraphHandler?.()
expect(updateSubgraphCreditsMock).not.toHaveBeenCalled()
})
})

View File

@@ -65,29 +65,4 @@ describe('useNodeCanvasImagePreview', () => {
expect(imagePreviewWidget).not.toHaveBeenCalled()
})
it('does nothing when removing from a node without widgets', () => {
const node = Object.assign(new LGraphNode('test'), { widgets: undefined })
useNodeCanvasImagePreview().removeCanvasImagePreview(node)
expect(imagePreviewWidget).not.toHaveBeenCalled()
})
it('removes an existing preview widget and calls its cleanup', () => {
const node = new LGraphNode('test')
const widget = node.addWidget(
'text',
'$$canvas-image-preview',
'',
() => undefined,
{}
)
widget.onRemove = vi.fn()
useNodeCanvasImagePreview().removeCanvasImagePreview(node)
expect(widget.onRemove).toHaveBeenCalledOnce()
expect(node.widgets).toHaveLength(0)
})
})

View File

@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
import { useNodeVideo } from '@/composables/node/useNodeImage'
import { createMockMediaNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils'
const { canvasInteractionsMock, nodeOutputStoreMock } = vi.hoisted(() => ({
@@ -28,24 +28,8 @@ describe('useNodeVideo', () => {
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function installMockImage() {
const images: HTMLImageElement[] = []
class MockImage {
onload: ((event: Event) => void) | null = null
onerror: ((event: Event) => void) | null = null
src = ''
constructor() {
images.push(this as unknown as HTMLImageElement)
}
}
vi.stubGlobal('Image', MockImage)
return images
}
async function setup() {
vi.clearAllMocks()
vi.useFakeTimers()
@@ -109,103 +93,4 @@ describe('useNodeVideo', () => {
expect(canvasInteractionsMock.handlePointerMove).not.toHaveBeenCalled()
expect(canvasInteractionsMock.handlePointerDown).not.toHaveBeenCalled()
})
it('loads image previews and marks the graph dirty', async () => {
vi.clearAllMocks()
vi.useFakeTimers()
const images = installMockImage()
const graph = { setDirtyCanvas: vi.fn() }
const node = createMockMediaNode({ graph })
const callback = vi.fn()
nodeOutputStoreMock.getNodeImageUrls.mockReturnValue(['http://image/1.png'])
const { showPreview } = useNodeImage(node, callback)
showPreview({ block: true })
images[0].onload?.(new Event('load'))
await vi.runAllTimersAsync()
expect(node.previewMediaType).toBe('image')
expect(node.imageIndex).toBeNull()
expect(node.imgs).toEqual([images[0]])
expect(node.isLoading).toBe(false)
expect(callback).toHaveBeenCalledTimes(1)
expect(graph.setDirtyCanvas).toHaveBeenCalledWith(true)
})
it('does not start image loads while already loading or without output URLs', () => {
vi.clearAllMocks()
const images = installMockImage()
const node = createMockMediaNode()
const { showPreview } = useNodeImage(node)
node.isLoading = true
showPreview()
node.isLoading = false
nodeOutputStoreMock.getNodeImageUrls.mockReturnValue(undefined)
showPreview()
expect(images).toHaveLength(0)
})
it('retries image loading once when the first load fails', async () => {
vi.clearAllMocks()
vi.useFakeTimers()
const images = installMockImage()
const graph = { setDirtyCanvas: vi.fn() }
const node = createMockMediaNode({ graph })
nodeOutputStoreMock.getNodeImageUrls.mockReturnValue([
'http://image/missing.png'
])
const staleImgs = [document.createElement('img')]
node.imgs = staleImgs
const { showPreview } = useNodeImage(node)
showPreview()
images[0].onerror?.(new Event('error'))
await Promise.resolve()
await Promise.resolve()
images[1].onerror?.(new Event('error'))
await vi.runAllTimersAsync()
expect(images).toHaveLength(2)
// Failed loads never resolve to elements, so existing previews are untouched
expect(node.imgs).toBe(staleImgs)
expect(graph.setDirtyCanvas).not.toHaveBeenCalled()
expect(node.isLoading).toBe(false)
})
it('reuses an existing video-preview widget when loading a video', async () => {
vi.clearAllMocks()
vi.useFakeTimers()
nodeOutputStoreMock.getNodeImageUrls.mockReturnValue(['http://video/1.mp4'])
const node = createMockMediaNode({
graph: { setDirtyCanvas: vi.fn() }
})
node.widgets.push({
name: 'video-preview',
element: document.createElement('div')
})
const callback = vi.fn()
const createdVideos: HTMLVideoElement[] = []
const realCreateElement = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation(
(tag: string, opts?: ElementCreationOptions) => {
const el = realCreateElement(tag, opts)
if (tag === 'video') createdVideos.push(el as HTMLVideoElement)
return el
}
)
const { showPreview } = useNodeVideo(node, callback)
showPreview()
const video = createdVideos[0]
video.onloadeddata?.(new Event('loadeddata'))
await vi.runAllTimersAsync()
expect(node.addDOMWidget).not.toHaveBeenCalled()
expect(node.videoContainer?.firstChild).toBe(video)
expect(callback).toHaveBeenCalledTimes(1)
})
})

View File

@@ -2,7 +2,7 @@ import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
import type { ResultItem } from '@/schemas/apiSchema'
const { mockFetchApi, mockAddAlert, mockUpdateInputs } = vi.hoisted(() => ({
mockFetchApi: vi.fn(),
@@ -11,41 +11,22 @@ const { mockFetchApi, mockAddAlert, mockUpdateInputs } = vi.hoisted(() => ({
}))
let capturedDragOnDrop: (files: File[]) => Promise<string[]>
let capturedResultItemDrop: (item: ResultItem) => void
let capturedPasteOnPaste: (files: File[]) => Promise<string[]>
let capturedFileInputOnSelect: (files: File[]) => Promise<string[]>
const mockOpenFileSelection = vi.fn()
vi.mock('@/composables/node/useNodeDragAndDrop', () => ({
useNodeDragAndDrop: (
_node: LGraphNode,
opts: {
onDrop: typeof capturedDragOnDrop
onResultItemDrop: typeof capturedResultItemDrop
}
opts: { onDrop: typeof capturedDragOnDrop }
) => {
capturedDragOnDrop = opts.onDrop
capturedResultItemDrop = opts.onResultItemDrop
}
}))
vi.mock('@/composables/node/useNodeFileInput', () => ({
useNodeFileInput: (
_node: LGraphNode,
opts: { onSelect: typeof capturedFileInputOnSelect }
) => {
capturedFileInputOnSelect = opts.onSelect
return { openFileSelection: mockOpenFileSelection }
}
useNodeFileInput: () => ({ openFileSelection: vi.fn() })
}))
vi.mock('@/composables/node/useNodePaste', () => ({
useNodePaste: (
_node: LGraphNode,
opts: { onPaste: typeof capturedPasteOnPaste }
) => {
capturedPasteOnPaste = opts.onPaste
}
useNodePaste: vi.fn()
}))
vi.mock('@/i18n', () => ({
@@ -97,26 +78,6 @@ describe('useNodeImageUpload', () => {
let onUploadStart: (files: File[]) => void
let onUploadError: () => void
async function mountImageUpload(
options: { folder?: ResultItemType } = { folder: 'input' }
) {
const { useNodeImageUpload } = await import('./useNodeImageUpload')
return useNodeImageUpload(node, {
onUploadComplete,
onUploadStart,
onUploadError,
...options
})
}
function lastUploadBody() {
const body = mockFetchApi.mock.calls.at(-1)?.[1]?.body
if (!(body instanceof FormData)) {
throw new Error('Expected upload body to be FormData')
}
return body
}
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
@@ -125,7 +86,13 @@ describe('useNodeImageUpload', () => {
onUploadStart = vi.fn()
onUploadError = vi.fn()
await mountImageUpload()
const { useNodeImageUpload } = await import('./useNodeImageUpload')
useNodeImageUpload(node, {
onUploadComplete,
onUploadStart,
onUploadError,
folder: 'input'
})
})
it.for([
@@ -213,60 +180,4 @@ describe('useNodeImageUpload', () => {
await capturedDragOnDrop([createFile()])
expect(node.graph?.setDirtyCanvas).toHaveBeenCalledTimes(2)
})
it('passes dropped result items through without uploading', () => {
const resultItem = fromAny<ResultItem, unknown>({
filename: 'existing.png',
subfolder: '',
type: 'input'
})
capturedResultItemDrop(resultItem)
expect(onUploadComplete).toHaveBeenCalledWith([resultItem])
expect(mockFetchApi).not.toHaveBeenCalled()
})
it('uploads pasted images to the pasted subfolder', async () => {
const { handleUpload } = await mountImageUpload({})
mockFetchApi.mockResolvedValueOnce(successResponse('image.png'))
await handleUpload(createFile('image.png'))
const body = lastUploadBody()
expect(body.get('subfolder')).toBe('pasted')
expect(body.get('type')).toBeNull()
expect(mockUpdateInputs).not.toHaveBeenCalled()
})
it('refreshes input assets for default non-pasted uploads', async () => {
const { handleUpload } = await mountImageUpload({})
mockFetchApi.mockResolvedValueOnce(successResponse('upload.png'))
await handleUpload(createFile('upload.png'))
const body = lastUploadBody()
expect(body.get('subfolder')).toBeNull()
expect(body.get('type')).toBeNull()
expect(mockUpdateInputs).toHaveBeenCalledOnce()
})
it('does not refresh input assets for explicit output uploads', async () => {
await mountImageUpload({ folder: 'output' })
mockFetchApi.mockResolvedValueOnce(successResponse('output.png'))
await capturedFileInputOnSelect([createFile('output.png')])
const body = lastUploadBody()
expect(body.get('type')).toBe('output')
expect(mockUpdateInputs).not.toHaveBeenCalled()
})
it('shows a specific alert for upload timeouts', async () => {
mockFetchApi.mockRejectedValueOnce(new DOMException('', 'TimeoutError'))
await capturedPasteOnPaste([createFile()])
expect(mockAddAlert).toHaveBeenCalledWith('g.uploadTimedOut')
})
})

View File

@@ -1,7 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits'
import {
@@ -15,7 +12,6 @@ import {
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'
@@ -127,35 +123,6 @@ function createMockNode(
})
}
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
// -----------------------------------------------------------------------------
@@ -222,32 +189,6 @@ describe('useNodePricing', () => {
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(
@@ -281,19 +222,6 @@ describe('useNodePricing', () => {
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(
@@ -310,64 +238,6 @@ describe('useNodePricing', () => {
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(
@@ -598,42 +468,6 @@ describe('useNodePricing', () => {
})
})
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()
@@ -707,43 +541,6 @@ describe('useNodePricing', () => {
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', () => {
@@ -798,107 +595,6 @@ describe('useNodePricing', () => {
})
})
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()
@@ -959,20 +655,6 @@ describe('useNodePricing', () => {
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', () => {
@@ -1061,16 +743,6 @@ describe('useNodePricing', () => {
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(
@@ -1296,21 +968,8 @@ describe('formatPricingResult', () => {
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: ' ' })
const result = formatPricingResult({ type: 'usd', usd: null as never })
expect(result).toBe('')
})
})
@@ -1340,14 +999,6 @@ describe('formatPricingResult', () => {
)
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', () => {
@@ -1366,22 +1017,6 @@ describe('formatPricingResult', () => {
)
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', () => {
@@ -1389,11 +1024,6 @@ describe('formatPricingResult', () => {
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', () => {
@@ -1538,20 +1168,6 @@ describe('evaluateNodeDefPricing', () => {
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',
@@ -1574,29 +1190,6 @@ describe('evaluateNodeDefPricing', () => {
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',
@@ -1672,30 +1265,6 @@ describe('evaluateNodeDefPricing', () => {
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',

View File

@@ -1,89 +0,0 @@
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
const mockTextPreviewWidget = vi.hoisted(() => vi.fn())
vi.mock(
'@/renderer/extensions/vueNodes/widgets/composables/useProgressTextWidget',
() => ({
useTextPreviewWidget: () => mockTextPreviewWidget
})
)
import { useNodeProgressText } from './useNodeProgressText'
function node(widgets?: IBaseWidget[]): LGraphNode {
return fromAny({
widgets,
setDirtyCanvas: vi.fn()
})
}
describe('useNodeProgressText', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTextPreviewWidget.mockImplementation(
(_node: LGraphNode, spec: { name: string; type: string }) => ({
name: spec.name,
type: spec.type,
value: ''
})
)
})
it('updates an existing text preview widget', () => {
const existing = { name: '$$node-text-preview', value: '' } as IBaseWidget
const graphNode = node([existing])
const { showTextPreview } = useNodeProgressText()
showTextPreview(graphNode, 'running')
expect(existing.value).toBe('running')
expect(mockTextPreviewWidget).not.toHaveBeenCalled()
expect(graphNode.setDirtyCanvas).toHaveBeenCalledWith(true)
})
it('creates a text preview widget when one is missing', () => {
const graphNode = node([])
const { showTextPreview } = useNodeProgressText()
showTextPreview(graphNode, 'queued')
expect(mockTextPreviewWidget).toHaveBeenCalledWith(graphNode, {
name: '$$node-text-preview',
type: 'progressText'
})
expect(mockTextPreviewWidget.mock.results[0].value.value).toBe('queued')
})
it('removes an existing preview widget and calls its cleanup', () => {
const onRemove = vi.fn()
const keep = { name: 'other' } as IBaseWidget
const preview = fromAny<IBaseWidget, unknown>({
name: '$$node-text-preview',
onRemove
})
const graphNode = node([keep, preview])
const { removeTextPreview } = useNodeProgressText()
removeTextPreview(graphNode)
expect(onRemove).toHaveBeenCalledOnce()
expect(graphNode.widgets).toEqual([keep])
})
it('does nothing when there are no widgets or no preview widget', () => {
const { removeTextPreview } = useNodeProgressText()
const withoutWidgets = node()
const withoutPreview = node([{ name: 'other' } as IBaseWidget])
removeTextPreview(withoutWidgets)
removeTextPreview(withoutPreview)
expect(withoutWidgets.widgets).toBeUndefined()
expect(withoutPreview.widgets).toHaveLength(1)
})
})

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
@@ -6,34 +6,26 @@ import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { subgraphTest } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphFixtures'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
import { adjustColor } from '@/utils/colorUtil'
const getNodeDisplayPrice = vi.fn(
(_node: LGraphNode, overrides?: ReadonlyMap<string, unknown>) =>
String(overrides?.get('prompt') ?? 'missing override')
)
const mockPalette = vi.hoisted(() => ({
completedActivePalette: {
light_theme: false,
colors: {
litegraph_base: {
BADGE_FG_COLOR: '#ffffff'
}
}
}
}))
vi.mock('@/composables/node/useNodePricing', () => ({
useNodePricing: () => ({ getNodeDisplayPrice })
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: () => mockPalette
useColorPaletteStore: () => ({
completedActivePalette: {
light_theme: false,
colors: { litegraph_base: {} }
}
})
}))
const { updateSubgraphCredits, getCreditsBadge, isCreditsBadge } =
usePriceBadge()
const { updateSubgraphCredits, getCreditsBadge } = usePriceBadge()
const mockNode = new LGraphNode('mock node')
mockNode.badges = [getCreditsBadge('$0.05/Run')]
@@ -44,34 +36,6 @@ function getBadgeText(node: LGraphNode): string {
}
describe('subgraph pricing', () => {
beforeEach(() => {
mockPalette.completedActivePalette.light_theme = false
})
it('identifies credit badges and ignores unrelated badges', () => {
expect(isCreditsBadge(getCreditsBadge('$0.05/Run'))).toBe(true)
expect(isCreditsBadge(() => getCreditsBadge('$0.05/Run'))).toBe(true)
expect(isCreditsBadge({ text: 'other' })).toBe(false)
})
it('uses the adjusted credits background in light themes', () => {
mockPalette.completedActivePalette.light_theme = true
expect(getCreditsBadge('$0.05/Run').bgColor).toBe(
adjustColor('#8D6932', { lightness: 0.5 })
)
})
it('does nothing for non-subgraph nodes', () => {
const node = new LGraphNode('plain node')
const badge = getCreditsBadge('$0.05/Run')
node.badges = [badge]
updateSubgraphCredits(node)
expect(node.badges).toEqual([badge])
})
subgraphTest(
'should not display badge for subgraphs without API nodes',
({ subgraphWithNode }) => {

View File

@@ -151,9 +151,7 @@ describe('useComputedWithWidgetWatch', () => {
})
it('should handle nodes without widgets gracefully', () => {
const mockNode = Object.assign(createMockLGraphNode(), {
widgets: undefined
}) as LGraphNode
const mockNode = createMockNode([])
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode)
@@ -162,85 +160,6 @@ describe('useComputedWithWidgetWatch', () => {
expect(computedValue.value).toBe('no widgets')
})
it('observes named input connection changes when requested', async () => {
const mockNode = Object.assign(
createMockNode([{ name: 'width', value: 1 }]),
{
inputs: [{ name: 'image' }],
onConnectionsChange: undefined as
| ((type: unknown, index: number, isConnected: boolean) => void)
| undefined
}
)
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
widgetNames: ['width', 'image'],
triggerCanvasRedraw: true
})
let runs = 0
const computedValue = computedWithWidgetWatch(() => ++runs)
expect(computedValue.value).toBe(1)
mockNode.onConnectionsChange?.('input', 0, true)
await nextTick()
expect(computedValue.value).toBe(2)
expect(mockNode.graph?.setDirtyCanvas).toHaveBeenCalledWith(true, true)
})
it('observes connection changes for watched inputs at non-zero slots', async () => {
const mockNode = Object.assign(
createMockNode([{ name: 'width', value: 1 }]),
{
inputs: [{ name: 'other' }, { name: 'image' }],
onConnectionsChange: undefined as
| ((type: unknown, index: number, isConnected: boolean) => void)
| undefined
}
)
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
widgetNames: ['width', 'image'],
triggerCanvasRedraw: true
})
let runs = 0
const computedValue = computedWithWidgetWatch(() => ++runs)
expect(computedValue.value).toBe(1)
mockNode.onConnectionsChange?.('input', 1, true)
await nextTick()
expect(computedValue.value).toBe(2)
expect(mockNode.graph?.setDirtyCanvas).toHaveBeenCalledWith(true, true)
})
it('ignores unobserved input connection changes', async () => {
const mockNode = Object.assign(
createMockNode([{ name: 'width', value: 1 }]),
{
inputs: [{ name: 'image' }],
onConnectionsChange: undefined as
| ((type: unknown, index: number, isConnected: boolean) => void)
| undefined
}
)
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
widgetNames: ['width', 'image']
})
let runs = 0
const computedValue = computedWithWidgetWatch(() => ++runs)
expect(computedValue.value).toBe(1)
mockNode.onConnectionsChange?.('input', 1, true)
await nextTick()
expect(computedValue.value).toBe(1)
})
it('should chain with existing widget callbacks', async () => {
const existingCallback = vi.fn()
const mockNode = createMockNode([

View File

@@ -1,15 +1,12 @@
import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { StrokeProcessor } from '@/composables/maskeditor/StrokeProcessor'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
@@ -30,12 +27,10 @@ vi.mock('@vueuse/core', () => ({
}))
vi.mock('@/composables/maskeditor/StrokeProcessor', () => ({
StrokeProcessor: vi.fn(function StrokeProcessor() {
return {
addPoint: vi.fn(() => []),
endStroke: vi.fn(() => [])
}
})
StrokeProcessor: vi.fn(() => ({
addPoint: vi.fn(() => []),
endStroke: vi.fn(() => [])
}))
}))
vi.mock('@/platform/distribution/types', () => ({
@@ -47,15 +42,14 @@ vi.mock('@/platform/updates/common/toastStore', () => {
return { useToastStore: () => store }
})
const mockNodeOutputStore = vi.hoisted(() => ({
getNodeImageUrls: vi.fn(() => undefined as string[] | undefined),
nodeOutputs: {},
nodePreviewImages: {}
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => mockNodeOutputStore
}))
vi.mock('@/stores/nodeOutputStore', () => {
const store = {
getNodeImageUrls: vi.fn(() => undefined),
nodeOutputs: {},
nodePreviewImages: {}
}
return { useNodeOutputStore: () => store }
})
vi.mock('@/scripts/api', () => ({
api: {
@@ -67,7 +61,7 @@ vi.mock('@/scripts/api', () => ({
const mockWidgets: IBaseWidget[] = []
const mockProperties: Record<string, unknown> = {}
const mockIsInputConnected = vi.fn(() => false)
const mockGetInputNode = vi.fn((): LGraphNode | null => null)
const mockGetInputNode = vi.fn(() => null)
vi.mock('@/scripts/app', () => ({
app: {
@@ -99,6 +93,9 @@ function makeWidget(name: string, value: unknown = null): IBaseWidget {
} as unknown as IBaseWidget
}
/**
* Mounts a thin wrapper component so Vue lifecycle hooks fire.
*/
function mountPainter(
nodeId: NodeId = toNodeId('test-node'),
initialModelValue = ''
@@ -122,94 +119,11 @@ function mountPainter(
}
})
const rendered = render(Wrapper)
return { painter, canvasEl, cursorEl, modelValue, unmount: rendered.unmount }
}
function createCanvasContext() {
const gradient = { addColorStop: vi.fn() }
return {
beginPath: vi.fn(),
arc: vi.fn(),
fill: vi.fn(),
createRadialGradient: vi.fn(() => gradient),
clearRect: vi.fn(),
drawImage: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fillStyle: '',
strokeStyle: '',
globalCompositeOperation: '',
globalAlpha: 1,
lineWidth: 1,
lineCap: 'butt',
lineJoin: 'miter'
} as unknown as CanvasRenderingContext2D
}
function createCanvasElement(
ctx: CanvasRenderingContext2D,
width = 100,
height = 100
) {
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
vi.spyOn(canvas, 'getContext').mockReturnValue(fromAny(ctx))
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
left: 0,
top: 0,
width,
height,
right: width,
bottom: height,
x: 0,
y: 0,
toJSON: vi.fn()
})
return canvas
}
function createPointerEvent(
type: string,
values: {
clientX?: number
clientY?: number
offsetX?: number
offsetY?: number
button?: number
pointerId?: number
target?: Pick<HTMLElement, 'setPointerCapture' | 'releasePointerCapture'>
} = {}
) {
const event = new PointerEvent(type, {
button: values.button ?? 0,
clientX: values.clientX ?? 0,
clientY: values.clientY ?? 0,
pointerId: values.pointerId ?? 1
})
Object.defineProperty(event, 'offsetX', { value: values.offsetX ?? 0 })
Object.defineProperty(event, 'offsetY', { value: values.offsetY ?? 0 })
Object.defineProperty(event, 'target', {
value:
values.target ??
({
setPointerCapture: vi.fn(),
releasePointerCapture: vi.fn()
} as Pick<HTMLElement, 'setPointerCapture' | 'releasePointerCapture'>)
})
return event
render(Wrapper)
return { painter, canvasEl, cursorEl, modelValue }
}
describe('usePainter', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.resetAllMocks()
@@ -219,7 +133,6 @@ describe('usePainter', () => {
}
mockIsInputConnected.mockReturnValue(false)
mockGetInputNode.mockReturnValue(null)
mockNodeOutputStore.getNodeImageUrls.mockReturnValue(undefined)
})
describe('syncCanvasSizeFromWidgets', () => {
@@ -238,25 +151,6 @@ describe('usePainter', () => {
expect(painter.canvasWidth.value).toBe(512)
expect(painter.canvasHeight.value).toBe(512)
})
it('keeps defaults when the node id is empty', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
const { painter } = mountPainter(toNodeId(''))
expect(app.canvas.graph!.getNodeById).not.toHaveBeenCalled()
expect(painter.canvasWidth.value).toBe(512)
expect(painter.canvasHeight.value).toBe(512)
expect(painter.inputImageUrl.value).toBeNull()
expect(painter.isImageInputConnected.value).toBe(false)
expect(maskWidget.serializeValue).toBeUndefined()
painter.brushSize.value = 36
await nextTick()
expect(mockProperties.painterBrushSize).toBeUndefined()
})
})
describe('restoreSettingsFromProperties', () => {
@@ -332,18 +226,6 @@ describe('usePainter', () => {
expect(widthWidget.callback).toHaveBeenCalledWith(800)
expect(heightWidget.callback).toHaveBeenCalledWith(600)
})
it('skips widget callbacks when dimensions are unchanged', async () => {
const widthWidget = makeWidget('width', 512)
const heightWidget = makeWidget('height', 512)
mockWidgets.push(widthWidget, heightWidget)
mountPainter()
await nextTick()
expect(widthWidget.callback).not.toHaveBeenCalled()
expect(heightWidget.callback).not.toHaveBeenCalled()
})
})
describe('syncBackgroundColorToWidget', () => {
@@ -359,16 +241,6 @@ describe('usePainter', () => {
expect(bgWidget.value).toBe('#ff00ff')
expect(bgWidget.callback).toHaveBeenCalledWith('#ff00ff')
})
it('skips widget callbacks when the background color is unchanged', async () => {
const bgWidget = makeWidget('bg_color', '#000000')
mockWidgets.push(bgWidget)
mountPainter()
await nextTick()
expect(bgWidget.callback).not.toHaveBeenCalled()
})
})
describe('updateInputImageUrl', () => {
@@ -386,34 +258,6 @@ describe('usePainter', () => {
expect(painter.isImageInputConnected.value).toBe(true)
})
it('sets inputImageUrl from the connected input node output', () => {
const inputNode = {} as LGraphNode
mockIsInputConnected.mockReturnValue(true)
mockGetInputNode.mockReturnValue(inputNode)
mockNodeOutputStore.getNodeImageUrls.mockReturnValue([
'http://localhost:8188/view?filename=input.png'
])
const { painter } = mountPainter()
expect(mockNodeOutputStore.getNodeImageUrls).toHaveBeenCalledWith(
inputNode
)
expect(painter.inputImageUrl.value).toBe(
'http://localhost:8188/view?filename=input.png'
)
})
it('keeps inputImageUrl null when a connected input has no images', () => {
mockIsInputConnected.mockReturnValue(true)
mockGetInputNode.mockReturnValue({} as LGraphNode)
mockNodeOutputStore.getNodeImageUrls.mockReturnValue([])
const { painter } = mountPainter()
expect(painter.inputImageUrl.value).toBeNull()
})
})
describe('handleInputImageLoad', () => {
@@ -438,20 +282,6 @@ describe('usePainter', () => {
expect(widthWidget.value).toBe(1920)
expect(heightWidget.value).toBe(1080)
})
it('updates canvas size when dimension widgets are absent', () => {
const { painter } = mountPainter()
painter.handleInputImageLoad({
target: {
naturalWidth: 320,
naturalHeight: 240
}
} as unknown as Event)
expect(painter.canvasWidth.value).toBe(320)
expect(painter.canvasHeight.value).toBe(240)
})
})
describe('cursor visibility', () => {
@@ -469,17 +299,6 @@ describe('usePainter', () => {
painter.handlePointerLeave()
expect(painter.cursorVisible.value).toBe(false)
})
it('positions the custom cursor on pointer movement', () => {
const { painter, cursorEl } = mountPainter()
cursorEl.value = document.createElement('div')
painter.handlePointerMove(
createPointerEvent('pointermove', { offsetX: 25, offsetY: 30 })
)
expect(cursorEl.value.style.transform).toBe('translate(15px, 20px)')
})
})
describe('displayBrushSize', () => {
@@ -603,123 +422,6 @@ describe('usePainter', () => {
).rejects.toThrow(/missing 'name'/)
})
it('throws when the upload request fails', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
vi.mocked(api.fetchApi).mockRejectedValueOnce(new Error('offline'))
const fakeCanvas = {
width: 4,
height: 4,
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
} as unknown as HTMLCanvasElement
const { canvasEl } = mountPainter(toNodeId('test-node'), '')
canvasEl.value = fakeCanvas
await nextTick()
await expect(
maskWidget.serializeValue!({} as LGraphNode, 0)
).rejects.toThrow(/painter\.uploadError/)
})
it('reports non-error upload rejections', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
vi.mocked(api.fetchApi).mockRejectedValueOnce('offline')
const fakeCanvas = {
width: 4,
height: 4,
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
} as unknown as HTMLCanvasElement
const { canvasEl } = mountPainter(toNodeId('test-node'), '')
canvasEl.value = fakeCanvas
await nextTick()
await expect(
maskWidget.serializeValue!({} as LGraphNode, 0)
).rejects.toThrow(/offline/)
})
it('throws when the upload response is not successful', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
vi.mocked(api.fetchApi).mockResolvedValueOnce({
status: 500,
statusText: 'Internal Server Error',
text: async () => 'upload failed'
} as Response)
const fakeCanvas = {
width: 4,
height: 4,
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
} as unknown as HTMLCanvasElement
const { canvasEl } = mountPainter(toNodeId('test-node'), '')
canvasEl.value = fakeCanvas
await nextTick()
await expect(
maskWidget.serializeValue!({} as LGraphNode, 0)
).rejects.toThrow(/upload failed/)
})
it('uses statusText when an unsuccessful upload response has no body', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
vi.mocked(api.fetchApi).mockResolvedValueOnce({
status: 502,
statusText: 'Bad Gateway',
text: async () => ''
} as Response)
const fakeCanvas = {
width: 4,
height: 4,
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
} as unknown as HTMLCanvasElement
const { canvasEl } = mountPainter(toNodeId('test-node'), '')
canvasEl.value = fakeCanvas
await nextTick()
await expect(
maskWidget.serializeValue!({} as LGraphNode, 0)
).rejects.toThrow(/Bad Gateway/)
})
it('uses unknown error when an unsuccessful upload response has no detail', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
vi.mocked(api.fetchApi).mockResolvedValueOnce({
status: 500,
statusText: '',
text: async () => ''
} as Response)
const fakeCanvas = {
width: 4,
height: 4,
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
} as unknown as HTMLCanvasElement
const { canvasEl } = mountPainter(toNodeId('test-node'), '')
canvasEl.value = fakeCanvas
await nextTick()
await expect(
maskWidget.serializeValue!({} as LGraphNode, 0)
).rejects.toThrow(/unknown error/)
})
it('throws when the upload response body is not valid JSON', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
@@ -746,80 +448,6 @@ describe('usePainter', () => {
).rejects.toThrow(/painter\.uploadError/)
})
it('reports non-error JSON parse failures', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
vi.mocked(api.fetchApi).mockResolvedValueOnce({
status: 200,
json: async () => {
throw 'bad json'
}
} as unknown as Response)
const fakeCanvas = {
width: 4,
height: 4,
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
} as unknown as HTMLCanvasElement
const { canvasEl } = mountPainter(toNodeId('test-node'), '')
canvasEl.value = fakeCanvas
await nextTick()
await expect(
maskWidget.serializeValue!({} as LGraphNode, 0)
).rejects.toThrow(/bad json/)
})
it('returns modelValue when dirty canvas serialization produces no blob', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
const fakeCanvas = {
width: 4,
height: 4,
getContext: vi.fn(() => ({
clearRect: vi.fn()
})),
toBlob: (cb: BlobCallback) => cb(null)
} as unknown as HTMLCanvasElement
const { painter, canvasEl } = mountPainter(toNodeId('test-node'), '')
canvasEl.value = fakeCanvas
painter.handleClear()
await nextTick()
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(result).toBe('')
expect(api.fetchApi).not.toHaveBeenCalled()
})
it('returns existing modelValue when canvas serialization produces no blob', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
const fakeCanvas = {
width: 4,
height: 4,
toBlob: (cb: BlobCallback) => cb(null)
} as unknown as HTMLCanvasElement
const { canvasEl } = mountPainter(
toNodeId('test-node'),
'painter/cached.png [temp]'
)
canvasEl.value = fakeCanvas
await nextTick()
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(result).toBe('painter/cached.png [temp]')
expect(api.fetchApi).not.toHaveBeenCalled()
})
it('returns existing modelValue when canvas element is unmounted at serialize time', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
@@ -870,113 +498,6 @@ describe('usePainter', () => {
expect.stringContaining('type=temp')
)
})
it('defaults restored mask type to input when no type suffix exists', () => {
vi.mocked(api.apiURL).mockClear()
mountPainter(toNodeId('test-node'), 'plain.png')
expect(api.apiURL).toHaveBeenCalledWith(
expect.stringContaining('filename=plain.png')
)
expect(api.apiURL).toHaveBeenCalledWith(
expect.stringContaining('type=input')
)
expect(api.apiURL).not.toHaveBeenCalledWith(
expect.stringContaining('subfolder=')
)
})
it('does not restore a canvas when the mask value is blank', () => {
vi.mocked(api.apiURL).mockClear()
mountPainter(toNodeId('test-node'), ' ')
expect(api.apiURL).not.toHaveBeenCalled()
})
it('draws a restored mask after the image loads', () => {
const images: Array<{ onload: (() => void) | null }> = []
class FakeImage {
crossOrigin = ''
naturalWidth = 64
naturalHeight = 32
onload: (() => void) | null = null
onerror: (() => void) | null = null
src = ''
constructor() {
images.push(this)
}
}
vi.stubGlobal('Image', FakeImage)
const ctx = createCanvasContext()
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
fromAny(ctx)
)
const { painter, canvasEl } = mountPainter(
toNodeId('test-node'),
'painter/mask.png [temp]'
)
canvasEl.value = createCanvasElement(ctx)
images[0].onload?.()
expect(painter.canvasWidth.value).toBe(64)
expect(painter.canvasHeight.value).toBe(32)
expect(ctx.drawImage).toHaveBeenCalled()
})
it('ignores restored image loads after the canvas unmounts', () => {
const images: Array<{ onload: (() => void) | null }> = []
class FakeImage {
crossOrigin = ''
naturalWidth = 64
naturalHeight = 32
onload: (() => void) | null = null
onerror: (() => void) | null = null
src = ''
constructor() {
images.push(this)
}
}
vi.stubGlobal('Image', FakeImage)
const { painter } = mountPainter(
toNodeId('test-node'),
'painter/mask.png [temp]'
)
images[0].onload?.()
expect(painter.canvasWidth.value).toBe(512)
expect(painter.canvasHeight.value).toBe(512)
})
it('clears stale modelValue when restored image loading fails', () => {
const images: Array<{ onerror: (() => void) | null }> = []
class FakeImage {
crossOrigin = ''
naturalWidth = 64
naturalHeight = 32
onload: (() => void) | null = null
onerror: (() => void) | null = null
src = ''
constructor() {
images.push(this)
}
}
vi.stubGlobal('Image', FakeImage)
const { modelValue } = mountPainter(
toNodeId('test-node'),
'painter/mask.png [temp]'
)
images[0].onerror?.()
expect(modelValue.value).toBe('')
})
})
describe('handleClear', () => {
@@ -985,36 +506,6 @@ describe('usePainter', () => {
expect(() => painter.handleClear()).not.toThrow()
})
it('clears the canvas and marks the current mask dirty', async () => {
const maskWidget = makeWidget('mask', '')
const ctx = createCanvasContext()
mockWidgets.push(maskWidget)
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
fromAny(ctx)
)
const { painter, canvasEl, modelValue } = mountPainter(
toNodeId('test-node'),
'painter/cached.png [temp]'
)
canvasEl.value = createCanvasElement(ctx, 50, 40)
painter.handleClear()
await nextTick()
expect(ctx.clearRect).toHaveBeenCalledWith(0, 0, 50, 40)
expect(modelValue.value).toBe('')
vi.mocked(api.fetchApi).mockResolvedValueOnce({
status: 200,
json: async () => ({ name: 'cleared.png' })
} as Response)
await expect(
maskWidget.serializeValue!({} as LGraphNode, 0)
).resolves.toBe('cleared.png [input]')
})
})
describe('handlePointerDown', () => {
@@ -1056,176 +547,6 @@ describe('usePainter', () => {
expect(() => painter.handlePointerDown(event)).not.toThrow()
})
it('draws a hard brush stroke across pointer events', () => {
const ctx = createCanvasContext()
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
fromAny(ctx)
)
const { painter, canvasEl } = mountPainter()
canvasEl.value = createCanvasElement(ctx)
painter.handlePointerDown(
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
)
painter.handlePointerMove(
createPointerEvent('pointermove', {
clientX: 60,
clientY: 10,
offsetX: 60,
offsetY: 10
})
)
painter.handlePointerUp(createPointerEvent('pointerup'))
expect(ctx.arc).toHaveBeenCalled()
expect(ctx.moveTo).toHaveBeenCalled()
expect(ctx.lineTo).toHaveBeenCalled()
expect(ctx.stroke).toHaveBeenCalled()
expect(ctx.drawImage).toHaveBeenCalled()
})
it('draws a soft brush stroke with radial dabs', () => {
const ctx = createCanvasContext()
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
fromAny(ctx)
)
const { painter, canvasEl } = mountPainter()
canvasEl.value = createCanvasElement(ctx)
painter.brushHardness.value = 0.5
painter.handlePointerDown(
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
)
painter.handlePointerMove(
createPointerEvent('pointermove', { clientX: 70, clientY: 10 })
)
painter.handlePointerUp(createPointerEvent('pointerup'))
expect(ctx.createRadialGradient).toHaveBeenCalled()
expect(ctx.arc).toHaveBeenCalled()
expect(ctx.drawImage).toHaveBeenCalled()
})
it('uses destination-out composition for eraser strokes', () => {
const ctx = createCanvasContext()
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
fromAny(ctx)
)
const { painter, canvasEl } = mountPainter()
canvasEl.value = createCanvasElement(ctx)
painter.tool.value = 'eraser'
painter.handlePointerDown(
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
)
expect(ctx.globalCompositeOperation).toBe('destination-out')
})
it('does not start drawing when a canvas context is unavailable', () => {
const canvas = document.createElement('canvas')
vi.spyOn(canvas, 'getContext').mockReturnValue(null)
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
left: 0,
top: 0,
width: 100,
height: 100,
right: 100,
bottom: 100,
x: 0,
y: 0,
toJSON: vi.fn()
})
const { painter, canvasEl } = mountPainter()
canvasEl.value = canvas
painter.handlePointerDown(
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
)
painter.handlePointerMove(
createPointerEvent('pointermove', { clientX: 20, clientY: 20 })
)
painter.handlePointerUp(createPointerEvent('pointerup'))
expect(canvas.getContext).toHaveBeenCalled()
})
it('uses one animation frame for pending pointer movement', () => {
const ctx = createCanvasContext()
let frameCallback: FrameRequestCallback | undefined
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(
(callback) => {
frameCallback = callback
return 7
}
)
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
fromAny(ctx)
)
const { painter, canvasEl } = mountPainter()
canvasEl.value = createCanvasElement(ctx)
painter.handlePointerDown(
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
)
painter.handlePointerMove(
createPointerEvent('pointermove', { clientX: 20, clientY: 20 })
)
painter.handlePointerMove(
createPointerEvent('pointermove', { clientX: 30, clientY: 30 })
)
expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1)
frameCallback?.(0)
expect(ctx.lineTo).toHaveBeenCalled()
})
it('flushes a pending pointer movement when leaving the canvas', () => {
const ctx = createCanvasContext()
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(7)
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
fromAny(ctx)
)
const { painter, canvasEl } = mountPainter()
canvasEl.value = createCanvasElement(ctx)
painter.handlePointerDown(
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
)
painter.handlePointerMove(
createPointerEvent('pointermove', { clientX: 20, clientY: 20 })
)
painter.handlePointerLeave()
expect(window.cancelAnimationFrame).toHaveBeenCalledWith(7)
expect(ctx.lineTo).toHaveBeenCalled()
})
it('cancels a pending pointer movement when unmounted', () => {
const ctx = createCanvasContext()
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(7)
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
fromAny(ctx)
)
const { painter, canvasEl, unmount } = mountPainter()
canvasEl.value = createCanvasElement(ctx)
painter.handlePointerDown(
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
)
painter.handlePointerMove(
createPointerEvent('pointermove', { clientX: 20, clientY: 20 })
)
unmount()
expect(window.cancelAnimationFrame).toHaveBeenCalledWith(7)
})
})
describe('handlePointerUp', () => {
@@ -1260,32 +581,5 @@ describe('usePainter', () => {
expect(() => painter.handlePointerUp(event)).not.toThrow()
})
it('draws final stroke processor points on release', () => {
const ctx = createCanvasContext()
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
fromAny(ctx)
)
vi.mocked(StrokeProcessor).mockImplementationOnce(
class MockStrokeProcessor {
addPoint = vi.fn(() => [])
endStroke = vi.fn(() => [
{ x: 40, y: 10 },
{ x: 80, y: 10 }
])
} as unknown as typeof StrokeProcessor
)
const { painter, canvasEl } = mountPainter()
canvasEl.value = createCanvasElement(ctx)
painter.handlePointerDown(
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
)
painter.handlePointerUp(createPointerEvent('pointerup'))
expect(ctx.moveTo).toHaveBeenCalledWith(10, 10)
expect(ctx.lineTo).toHaveBeenCalledWith(40, 10)
expect(ctx.lineTo).toHaveBeenCalledWith(80, 10)
})
})
})

View File

@@ -12,12 +12,8 @@ afterEach(() => {
function setup(initial: string[]) {
const modelValue = ref(initial)
const container = shallowRef<HTMLDivElement | null>(
document.createElement('div')
)
const picker = shallowRef<HTMLInputElement | null>(
document.createElement('input')
)
const container = shallowRef(document.createElement('div'))
const picker = shallowRef(document.createElement('input'))
const scope = effectScope()
scopes.push(scope)
const api = scope.run(() =>
@@ -55,19 +51,6 @@ describe('usePaletteSwatchRow', () => {
expect(picker.value!.value).toBe('#ffffff')
})
it('tracks the picker index even when the input is unavailable', () => {
const { modelValue, picker, openPicker, onPickerInput } = setup([
'#000000',
'#111111'
])
picker.value = null
openPicker(1, mouseEvent())
onPickerInput({ target: { value: '#222222' } } as unknown as Event)
expect(modelValue.value).toEqual(['#000000', '#222222'])
})
it('writes the picked color back to the open slot', () => {
const { modelValue, openPicker, onPickerInput } = setup(['#a', '#b'])
openPicker(1, mouseEvent())
@@ -117,82 +100,6 @@ describe('usePaletteSwatchRow', () => {
expect(modelValue.value).toEqual(['#a', '#b'])
})
it('ignores pointer movement before a drag starts', () => {
const { modelValue } = setup(['#a', '#b'])
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
)
expect(modelValue.value).toEqual(['#a', '#b'])
})
it('waits until movement passes the drag threshold', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
const swatch = document.createElement('div')
swatch.setAttribute('data-index', '1')
container.value!.appendChild(swatch)
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 12, clientY: 11, buttons: 1 })
)
expect(modelValue.value).toEqual(['#a', '#b'])
})
it('ignores active drags when the row container is gone', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
container.value = null
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
)
expect(modelValue.value).toEqual(['#a', '#b'])
})
it('ignores invalid target rows during drag', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
const current = document.createElement('div')
current.setAttribute('data-index', '0')
const invalid = document.createElement('div')
invalid.setAttribute('data-index', '-1')
container.value!.append(current, invalid)
invalid.getBoundingClientRect = () =>
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
)
expect(modelValue.value).toEqual(['#a', '#b'])
})
it('cancels drags on pointerup and pointercancel', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
const swatch = document.createElement('div')
swatch.setAttribute('data-index', '1')
container.value!.appendChild(swatch)
swatch.getBoundingClientRect = () =>
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(new PointerEvent('pointerup'))
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
)
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(new PointerEvent('pointercancel'))
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
)
expect(modelValue.value).toEqual(['#a', '#b'])
})
it('ignores non-left-button pointer downs', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
const swatch = document.createElement('div')

View File

@@ -1,61 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { distribution, downloads } = vi.hoisted(() => ({
distribution: { isDesktop: false },
downloads: { values: [] as unknown[] }
}))
vi.mock('@/components/sidebar/tabs/ModelLibrarySidebarTab.vue', () => ({
default: {}
}))
vi.mock('@/platform/distribution/types', () => ({
get isDesktop() {
return distribution.isDesktop
}
}))
vi.mock('@/stores/electronDownloadStore', () => ({
useElectronDownloadStore: () => ({
inProgressDownloads: downloads.values
})
}))
describe('useModelLibrarySidebarTab', () => {
beforeEach(() => {
distribution.isDesktop = false
downloads.values = []
})
it('hides the badge outside desktop builds', async () => {
distribution.isDesktop = false
downloads.values = [{ id: 'download-1' }]
const { useModelLibrarySidebarTab } =
await import('./useModelLibrarySidebarTab')
const sidebarTab = useModelLibrarySidebarTab()
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
})
it('shows active desktop download count', async () => {
distribution.isDesktop = true
downloads.values = [{ id: 'a' }, { id: 'b' }]
const { useModelLibrarySidebarTab } =
await import('./useModelLibrarySidebarTab')
const sidebarTab = useModelLibrarySidebarTab()
expect((sidebarTab.iconBadge as () => string | null)()).toBe('2')
})
it('hides the badge when desktop has no active downloads', async () => {
distribution.isDesktop = true
const { useModelLibrarySidebarTab } =
await import('./useModelLibrarySidebarTab')
const sidebarTab = useModelLibrarySidebarTab()
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
})
})

View File

@@ -1,48 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { settings } = vi.hoisted(() => ({
settings: { newDesign: false }
}))
const legacyComponent = { name: 'NodeLibrarySidebarTab' }
const newDesignComponent = { name: 'NodeLibrarySidebarTabV2' }
vi.mock('@/components/sidebar/tabs/NodeLibrarySidebarTab.vue', () => ({
default: legacyComponent
}))
vi.mock('@/components/sidebar/tabs/NodeLibrarySidebarTabV2.vue', () => ({
default: newDesignComponent
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) =>
key === 'Comfy.NodeLibrary.NewDesign' && settings.newDesign
})
}))
describe('useNodeLibrarySidebarTab', () => {
beforeEach(() => {
settings.newDesign = false
})
it('uses the legacy node library component by default', async () => {
const { useNodeLibrarySidebarTab } =
await import('./useNodeLibrarySidebarTab')
const tab = useNodeLibrarySidebarTab()
if (tab.type !== 'vue') throw new Error('Expected a vue sidebar tab')
expect(tab.component).toBe(legacyComponent)
})
it('uses the new node library component when the setting is enabled', async () => {
settings.newDesign = true
const { useNodeLibrarySidebarTab } =
await import('./useNodeLibrarySidebarTab')
const tab = useNodeLibrarySidebarTab()
if (tab.type !== 'vue') throw new Error('Expected a vue sidebar tab')
expect(tab.component).toBe(newDesignComponent)
})
})

View File

@@ -1,112 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { useTreeFolderOperations } from './useTreeFolderOperations'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
function makeFolder(
overrides: Partial<RenderedTreeExplorerNode> = {}
): RenderedTreeExplorerNode {
return {
key: 'root',
label: 'Root',
leaf: false,
children: [],
icon: 'pi pi-folder',
type: 'folder',
totalLeaves: 0,
...overrides
}
}
describe('useTreeFolderOperations', () => {
beforeEach(() => {
vi.spyOn(Date, 'now').mockReturnValue(123)
})
it('creates a temporary editable folder under the selected target', () => {
const expandNode = vi.fn()
const target = makeFolder({ key: 'models', handleAddFolder: vi.fn() })
const operations = useTreeFolderOperations(expandNode)
operations.addFolderCommand(target)
expect(expandNode).toHaveBeenCalledWith(target)
expect(operations.newFolderNode.value).toMatchObject({
key: 'models/new_folder_123',
label: '',
leaf: false,
icon: 'pi pi-folder',
type: 'folder',
isEditingLabel: true
})
})
it('passes the confirmed name to the target and clears temporary state', async () => {
const handleAddFolder = vi.fn()
const target = makeFolder({ handleAddFolder })
const operations = useTreeFolderOperations(vi.fn())
operations.addFolderCommand(target)
await operations.handleFolderCreation('New Folder')
expect(handleAddFolder).toHaveBeenCalledWith('New Folder')
expect(operations.newFolderNode.value).toBeNull()
})
it('clears temporary state even when folder creation fails', async () => {
const handleAddFolder = vi.fn().mockRejectedValue(new Error('failed'))
const target = makeFolder({ handleAddFolder })
const operations = useTreeFolderOperations(vi.fn())
operations.addFolderCommand(target)
await expect(operations.handleFolderCreation('New Folder')).rejects.toThrow(
'failed'
)
expect(operations.newFolderNode.value).toBeNull()
})
it('ignores folder creation when no target is pending', async () => {
const operations = useTreeFolderOperations(vi.fn())
await operations.handleFolderCreation('Unused')
expect(operations.newFolderNode.value).toBeNull()
})
it('returns a hidden menu item when the target cannot add folders', () => {
const operations = useTreeFolderOperations(vi.fn())
expect(operations.getAddFolderMenuItem(null)).toMatchObject({
label: 'g.newFolder',
visible: false,
isAsync: false
})
expect(
operations.getAddFolderMenuItem(makeFolder({ leaf: true }))
).toMatchObject({ visible: false })
expect(
operations.getAddFolderMenuItem(makeFolder({ leaf: false }))
).toMatchObject({ visible: false })
})
it('runs the add folder command from a visible menu item', () => {
const expandNode = vi.fn()
const target = makeFolder({ handleAddFolder: vi.fn() })
const operations = useTreeFolderOperations(expandNode)
const item = operations.getAddFolderMenuItem(target)
expect(item.visible).toBe(true)
item.command?.({ originalEvent: new Event('click'), item })
expect(expandNode).toHaveBeenCalledWith(target)
expect(operations.newFolderNode.value?.key).toBe('root/new_folder_123')
})
})

View File

@@ -1,285 +0,0 @@
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCanvasDrop } from '@/composables/useCanvasDrop'
type DropInput = {
clientX: number
clientY: number
}
type DropEvent = {
location: { current: { input: DropInput } }
source: { data: { type: string; data?: unknown } }
}
type DroppableOptions = {
getDropEffect: (
args: DropEvent
) => Exclude<DataTransfer['dropEffect'], 'none'>
onDrop: (event: DropEvent) => Promise<void>
}
const {
MockComfyModelDef,
MockComfyNodeDefImpl,
MockComfyWorkflow,
captured,
graph,
insertWorkflow,
addNodeOnGraph,
getNodeProvider,
getAllNodeProviders,
withNodeAddSource
} = vi.hoisted(() => {
class MockComfyNodeDefImpl {
name: string
constructor(name = 'NodeDef') {
this.name = name
}
}
class MockComfyModelDef {
directory: string
file_name: string
constructor(directory = 'checkpoints', fileName = 'model.safetensors') {
this.directory = directory
this.file_name = fileName
}
}
class MockComfyWorkflow {
id: string
constructor(id = 'workflow') {
this.id = id
}
}
return {
MockComfyModelDef,
MockComfyNodeDefImpl,
MockComfyWorkflow,
captured: {
options: undefined as DroppableOptions | undefined
},
graph: {
getNodeOnPos: vi.fn()
},
insertWorkflow: vi.fn(),
addNodeOnGraph: vi.fn(),
getNodeProvider: vi.fn(),
getAllNodeProviders: vi.fn(),
withNodeAddSource: vi.fn((_source: string, callback: () => unknown) =>
callback()
)
}
})
vi.mock('@/composables/element/useCanvasPositionConversion', () => ({
useSharedCanvasPositionConversion: () => ({
clientPosToCanvasPos: ([x, y]: [number, number]) => [x / 2, y / 2]
})
}))
vi.mock('@/composables/usePragmaticDragAndDrop', () => ({
usePragmaticDroppable: vi.fn(
(_target: () => HTMLCanvasElement | null, options: DroppableOptions) => {
captured.options = options
}
)
}))
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LiteGraph: { NODE_TITLE_HEIGHT: 24 }
}))
vi.mock('@/platform/telemetry/nodeAdded/nodeAddSource', () => ({
withNodeAddSource
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({ insertWorkflow })
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
ComfyWorkflow: MockComfyWorkflow
}))
vi.mock('@/scripts/app', () => ({
app: { canvas: { graph } }
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ addNodeOnGraph })
}))
vi.mock('@/stores/modelStore', () => ({
ComfyModelDef: MockComfyModelDef
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
getNodeProvider,
getAllNodeProviders
})
}))
vi.mock('@/stores/nodeDefStore', () => ({
ComfyNodeDefImpl: MockComfyNodeDefImpl
}))
function dropEvent(
data: unknown,
input: DropInput = { clientX: 20, clientY: 40 }
) {
return {
location: { current: { input } },
source: { data: { type: 'tree-explorer-node', data: { data } } }
}
}
function options() {
useCanvasDrop(ref(document.createElement('canvas')))
const value = captured.options
if (!value) throw new Error('droppable options were not registered')
return value
}
beforeEach(() => {
captured.options = undefined
graph.getNodeOnPos.mockReset()
insertWorkflow.mockReset()
addNodeOnGraph.mockReset()
getNodeProvider.mockReset()
getAllNodeProviders.mockReset()
withNodeAddSource.mockClear()
})
describe('useCanvasDrop', () => {
it('uses copy effect only for tree explorer nodes', () => {
const droppable = options()
expect(
droppable.getDropEffect({
...dropEvent(undefined),
source: { data: { type: 'tree-explorer-node' } }
})
).toBe('copy')
expect(
droppable.getDropEffect({
...dropEvent(undefined),
source: { data: { type: 'other' } }
})
).toBe('move')
})
it('adds dropped node definitions below the cursor', async () => {
const nodeDef = new MockComfyNodeDefImpl('KSampler')
const droppable = options()
await droppable.onDrop(dropEvent(nodeDef))
expect(withNodeAddSource).toHaveBeenCalledWith(
'sidebar_drag',
expect.any(Function)
)
expect(addNodeOnGraph).toHaveBeenCalledWith(nodeDef, {
pos: [10, 44]
})
})
it('ignores drops that do not come from tree explorer nodes', async () => {
const nodeDef = new MockComfyNodeDefImpl('KSampler')
const droppable = options()
await droppable.onDrop({
...dropEvent(nodeDef),
source: { data: { type: 'other', data: { data: nodeDef } } }
})
expect(addNodeOnGraph).not.toHaveBeenCalled()
expect(insertWorkflow).not.toHaveBeenCalled()
})
it('sets a model widget on an existing compatible node', async () => {
const widget = { name: 'ckpt_name', value: '' }
const node = { comfyClass: 'CheckpointLoaderSimple', widgets: [widget] }
const provider = {
key: 'ckpt_name',
nodeDef: { name: 'CheckpointLoaderSimple' }
}
graph.getNodeOnPos.mockReturnValue(node)
getAllNodeProviders.mockReturnValue([provider])
const droppable = options()
await droppable.onDrop(
dropEvent(new MockComfyModelDef('checkpoints', 'dream.safetensors'))
)
expect(widget.value).toBe('dream.safetensors')
expect(addNodeOnGraph).not.toHaveBeenCalled()
})
it('creates a provider node when the model has no compatible target', async () => {
const widget = { name: 'lora_name', value: '' }
const createdNode = { widgets: [widget] }
const provider = { key: 'lora_name', nodeDef: { name: 'LoraLoader' } }
graph.getNodeOnPos.mockReturnValue(undefined)
getNodeProvider.mockReturnValue(provider)
addNodeOnGraph.mockReturnValue(createdNode)
const droppable = options()
await droppable.onDrop(
dropEvent(new MockComfyModelDef('loras', 'style.safetensors'))
)
expect(addNodeOnGraph).toHaveBeenCalledWith(provider.nodeDef, {
pos: [10, 20]
})
expect(widget.value).toBe('style.safetensors')
})
it('does nothing for model drops without a compatible or default provider', async () => {
graph.getNodeOnPos.mockReturnValue({ comfyClass: 'OtherNode' })
getAllNodeProviders.mockReturnValue([
{ key: 'ckpt_name', nodeDef: { name: 'CheckpointLoaderSimple' } }
])
getNodeProvider.mockReturnValue(null)
const droppable = options()
await droppable.onDrop(
dropEvent(new MockComfyModelDef('checkpoints', 'dream.safetensors'))
)
expect(addNodeOnGraph).not.toHaveBeenCalled()
})
it('does not set a model value when the target node lacks the provider widget', async () => {
const provider = { key: 'lora_name', nodeDef: { name: 'LoraLoader' } }
const createdNode = { widgets: [{ name: 'other', value: '' }] }
graph.getNodeOnPos.mockReturnValue(undefined)
getNodeProvider.mockReturnValue(provider)
addNodeOnGraph.mockReturnValue(createdNode)
const droppable = options()
await droppable.onDrop(
dropEvent(new MockComfyModelDef('loras', 'style.safetensors'))
)
expect(createdNode.widgets[0].value).toBe('')
})
it('inserts dropped workflows at the canvas position', async () => {
const workflow = new MockComfyWorkflow('wf-1')
const droppable = options()
await droppable.onDrop(dropEvent(workflow))
expect(insertWorkflow).toHaveBeenCalledWith(workflow, {
position: [10, 20]
})
})
})

View File

@@ -1,250 +0,0 @@
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { IContextMenuOptions } from '@/lib/litegraph/src/interfaces'
const mockInstall = vi.hoisted(() => vi.fn())
const mockRegisterWrapper = vi.hoisted(() => vi.fn())
const mockExtractLegacyItems = vi.hoisted(() => vi.fn())
const mockCollectCanvasMenuItems = vi.hoisted(() => vi.fn())
const mockCollectNodeMenuItems = vi.hoisted(() => vi.fn())
const mockSt = vi.hoisted(() => vi.fn())
const mockTe = vi.hoisted(() => vi.fn())
const mockContextMenuConstructor = vi.hoisted(() => vi.fn())
const mockClasses = vi.hoisted(() => {
class LGraphCanvas {
getCanvasMenuOptions() {
return [{ content: 'Base' }, null]
}
getNodeMenuOptions(node: unknown) {
return [{ content: `Node:${String(node)}` }]
}
}
class ContextMenu {
constructor(values: unknown, options: unknown) {
mockContextMenuConstructor(values, options)
}
}
return {
LGraphCanvas,
ContextMenu,
LiteGraph: {
ContextMenu
}
}
})
vi.mock('@/lib/litegraph/src/contextMenuCompat', () => ({
legacyMenuCompat: {
install: mockInstall,
registerWrapper: mockRegisterWrapper,
extractLegacyItems: mockExtractLegacyItems
}
}))
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LGraphCanvas: mockClasses.LGraphCanvas,
LiteGraph: mockClasses.LiteGraph
}))
vi.mock('@/scripts/app', () => ({
app: {
collectCanvasMenuItems: mockCollectCanvasMenuItems,
collectNodeMenuItems: mockCollectNodeMenuItems
}
}))
vi.mock('@/i18n', () => ({
st: mockSt,
te: mockTe
}))
vi.mock('@/utils/formatUtil', () => ({
normalizeI18nKey: (value: string) => `normalized-${value}`
}))
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useContextMenuTranslation } from './useContextMenuTranslation'
describe('useContextMenuTranslation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCollectCanvasMenuItems.mockReturnValue([{ content: 'NewApi' }])
mockCollectNodeMenuItems.mockReturnValue([{ content: 'NodeApi' }])
mockExtractLegacyItems.mockReturnValue([{ content: 'Legacy' }])
mockSt.mockImplementation((_key: string, fallback: string) => {
return `translated:${fallback}`
})
mockTe.mockImplementation(
(key: string) => key === 'contextMenu.TranslateMe'
)
mockClasses.LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [{ content: 'Base' }, null]
}
mockClasses.LGraphCanvas.prototype.getNodeMenuOptions = function (
node: unknown
) {
return [{ content: `Node:${String(node)}` }]
}
mockClasses.LiteGraph.ContextMenu = mockClasses.ContextMenu
})
it('wraps canvas menu options with new API, legacy, and translated items', () => {
useContextMenuTranslation()
const canvas = new mockClasses.LGraphCanvas()
const result = canvas.getCanvasMenuOptions()
expect(mockInstall).toHaveBeenCalledWith(
mockClasses.LGraphCanvas.prototype,
'getCanvasMenuOptions'
)
expect(mockCollectCanvasMenuItems).toHaveBeenCalledWith(canvas)
expect(mockExtractLegacyItems).toHaveBeenCalledWith(
'getCanvasMenuOptions',
canvas
)
expect(result).toEqual([
{ content: 'translated:Base' },
null,
{ content: 'translated:NewApi' },
{ content: 'translated:Legacy' }
])
})
it('wraps node menu options with new API and legacy extension items', () => {
useContextMenuTranslation()
const canvas = new mockClasses.LGraphCanvas()
const result = canvas.getNodeMenuOptions('node')
expect(mockInstall).toHaveBeenCalledWith(
mockClasses.LGraphCanvas.prototype,
'getNodeMenuOptions'
)
expect(mockCollectNodeMenuItems).toHaveBeenCalledWith('node')
expect(mockExtractLegacyItems).toHaveBeenCalledWith(
'getNodeMenuOptions',
canvas,
'node'
)
expect(result).toEqual([
{ content: 'Node:node' },
{ content: 'NodeApi' },
{ content: 'Legacy' }
])
})
it('translates LiteGraph context menu titles, nested items, and conversion labels', () => {
useContextMenuTranslation()
const values = [
{ content: 'TranslateMe' },
{ content: 'Convert seed to input' },
{ content: 'Convert value to widget' },
{
content: '',
submenu: {
options: [{ content: 'TranslateMe' }]
}
},
'separator'
]
const options = {
title: 'KSampler',
extra: {
inputs: [{ name: 'seed', label: 'Seed Label' }],
widgets: [{ name: 'value', label: 'Value Label' }]
}
}
new LiteGraph.ContextMenu(values, options)
expect(options.title).toBe('translated:KSampler')
expect(values).toMatchObject([
{ content: 'translated:TranslateMe' },
{ content: 'translated:Convert Seed Labeltranslated: to input' },
{ content: 'translated:Convert Value Labeltranslated: to widget' },
{
submenu: {
options: [{ content: 'translated:TranslateMe' }]
}
},
'separator'
])
expect(mockContextMenuConstructor).toHaveBeenCalledWith(values, options)
})
it('uses parent menu extra data when direct options do not provide it', () => {
useContextMenuTranslation()
const values = [{ content: 'Convert latent to input' }]
const options = {
parentMenu: {
options: {
extra: {
inputs: [{ name: 'latent', label: 'Latent Label' }]
}
}
}
}
new LiteGraph.ContextMenu(
values,
fromAny<IContextMenuOptions<unknown, unknown>, unknown>(options)
)
expect(values[0].content).toBe(
'translated:Convert Latent Labeltranslated: to input'
)
})
it('keeps conversion names when matching inputs and widgets have no label', () => {
useContextMenuTranslation()
const values = [
{ content: 'Convert seed to input' },
{ content: 'Convert value to widget' }
]
const options = {
extra: {
inputs: [{ name: 'seed' }],
widgets: [{ name: 'value' }]
}
}
new LiteGraph.ContextMenu(values, options)
expect(values).toMatchObject([
{ content: 'translated:Convert seedtranslated: to input' },
{ content: 'translated:Convert valuetranslated: to widget' }
])
})
it('uses widget labels when input conversion names do not match inputs', () => {
useContextMenuTranslation()
const values = [{ content: 'Convert seed to input' }]
const options = {
extra: {
inputs: [{ name: 'other' }],
widgets: [{ name: 'seed', label: 'Widget Seed' }]
}
}
new LiteGraph.ContextMenu(values, options)
expect(values[0].content).toBe(
'translated:Convert Widget Seedtranslated: to input'
)
})
it('keeps plain unregistered menu items unchanged', () => {
useContextMenuTranslation()
const values = [{ content: 'Plain' }]
new LiteGraph.ContextMenu(values, {})
expect(values[0].content).toBe('Plain')
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -503,7 +503,7 @@ export function useCoreCommands(): ComfyCommand[] {
}) => {
trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog({ reason: 'subscribe_to_run' })
showSubscriptionDialog()
return
}
@@ -526,7 +526,7 @@ export function useCoreCommands(): ComfyCommand[] {
}) => {
trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog({ reason: 'subscribe_to_run' })
showSubscriptionDialog()
return
}
@@ -548,7 +548,7 @@ export function useCoreCommands(): ComfyCommand[] {
}) => {
trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog({ reason: 'subscribe_to_run' })
showSubscriptionDialog()
return
}

View File

@@ -1,124 +0,0 @@
import { fromAny } from '@total-typescript/shoehorn'
import { describe, expect, it, vi } from 'vitest'
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import type { KeyComboImpl } from '@/platform/keybindings/keyCombo'
import { DIALOG_KEY, useEditKeybindingDialog } from './useEditKeybindingDialog'
const mockShowSmallLayoutDialog = vi.fn()
const mockGetKeybinding = vi.fn()
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showSmallLayoutDialog: mockShowSmallLayoutDialog
})
}))
vi.mock('@/platform/keybindings/keybindingStore', () => ({
useKeybindingStore: () => ({
getKeybinding: mockGetKeybinding
})
}))
vi.mock(
'@/components/dialog/content/setting/keybinding/EditKeybindingContent.vue',
() => ({
default: { name: 'EditKeybindingContent' }
})
)
vi.mock(
'@/components/dialog/content/setting/keybinding/EditKeybindingFooter.vue',
() => ({
default: { name: 'EditKeybindingFooter' }
})
)
vi.mock(
'@/components/dialog/content/setting/keybinding/EditKeybindingHeader.vue',
() => ({
default: { name: 'EditKeybindingHeader' }
})
)
function makeCombo(label: string): KeyComboImpl {
return fromAny({
label,
equals: vi.fn((other: { label: string }) => other.label === label)
})
}
describe('useEditKeybindingDialog', () => {
it('opens the edit dialog with default edit state', () => {
const currentCombo = makeCombo('Ctrl+A')
useEditKeybindingDialog().show({
commandId: 'app.test',
commandLabel: 'Test command',
currentCombo
})
expect(mockShowSmallLayoutDialog).toHaveBeenCalledWith(
expect.objectContaining({
key: DIALOG_KEY,
props: expect.objectContaining({
commandLabel: 'Test command'
})
})
)
const dialog = mockShowSmallLayoutDialog.mock.calls[0][0]
expect(dialog.props.dialogState).toMatchObject({
commandId: 'app.test',
newCombo: currentCombo,
currentCombo,
mode: 'edit',
existingBinding: null
})
expect(dialog.footerProps.dialogState).toBe(dialog.props.dialogState)
})
it('updates combo state and reports a conflicting binding', () => {
const currentCombo = makeCombo('Ctrl+A')
const newCombo = makeCombo('Ctrl+B')
const binding = fromAny<KeybindingImpl, unknown>({
commandId: 'other.command'
})
mockGetKeybinding.mockReturnValue(binding)
useEditKeybindingDialog().show({
commandId: 'app.test',
commandLabel: 'Test command',
currentCombo,
mode: 'add',
existingBinding: binding
})
const dialog = mockShowSmallLayoutDialog.mock.calls.at(-1)![0]
dialog.props.onUpdateCombo(newCombo)
expect(dialog.props.dialogState.newCombo).toMatchObject({
label: 'Ctrl+B'
})
expect(dialog.props.existingKeybindingOnCombo.value).toBe(binding)
expect(mockGetKeybinding.mock.calls.at(-1)?.[0]).toMatchObject({
label: 'Ctrl+B'
})
})
it('does not report a conflict for an unchanged or empty combo', () => {
const currentCombo = makeCombo('Ctrl+A')
useEditKeybindingDialog().show({
commandId: 'app.test',
commandLabel: 'Test command',
currentCombo
})
const dialog = mockShowSmallLayoutDialog.mock.calls.at(-1)![0]
expect(dialog.props.existingKeybindingOnCombo.value).toBeNull()
dialog.props.dialogState.newCombo = null
expect(dialog.props.existingKeybindingOnCombo.value).toBeNull()
})
})

View File

@@ -6,11 +6,6 @@ import {
useFeatureFlags
} from '@/composables/useFeatureFlags'
import * as distributionTypes from '@/platform/distribution/types'
import {
cachedTeamWorkspacesEnabled,
remoteConfig,
remoteConfigState
} from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
// Mock the API module
@@ -29,13 +24,6 @@ vi.mock('@/platform/distribution/types', () => ({
describe('useFeatureFlags', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.unstubAllEnvs()
localStorage.clear()
remoteConfig.value = {}
remoteConfigState.value = 'unloaded'
cachedTeamWorkspacesEnabled.value = undefined
vi.mocked(distributionTypes).isCloud = false
vi.mocked(distributionTypes).isNightly = false
})
describe('flags object', () => {
@@ -231,69 +219,6 @@ describe('useFeatureFlags', () => {
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
})
it('teamWorkspacesEnabled is disabled outside cloud builds', () => {
vi.mocked(distributionTypes).isCloud = false
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(false)
})
it('teamWorkspacesEnabled uses the cached value before authenticated config loads', () => {
vi.mocked(distributionTypes).isCloud = true
cachedTeamWorkspacesEnabled.value = true
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
expect(api.getServerFeature).not.toHaveBeenCalled()
})
it('teamWorkspacesEnabled falls back to the server after authenticated config loads', () => {
vi.mocked(distributionTypes).isCloud = true
remoteConfigState.value = 'authenticated'
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED,
false
)
})
it('nodeLibraryEssentialsEnabled checks config outside nightly and dev builds', () => {
vi.stubEnv('DEV', false)
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.NODE_LIBRARY_ESSENTIALS_ENABLED)
return true
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.nodeLibraryEssentialsEnabled).toBe(true)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.NODE_LIBRARY_ESSENTIALS_ENABLED,
false
)
})
it('nodeLibraryEssentialsEnabled uses remote config before the server fallback', () => {
vi.stubEnv('DEV', false)
remoteConfig.value = {
node_library_essentials_enabled: true
}
const { flags } = useFeatureFlags()
expect(flags.nodeLibraryEssentialsEnabled).toBe(true)
expect(api.getServerFeature).not.toHaveBeenCalled()
})
})
describe('signupTurnstileMode', () => {

View File

@@ -1,591 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, nextTick } from 'vue'
import type { App } from 'vue'
const mockStats = {
min: 0,
max: 4,
mean: 1,
stdDev: 0.5,
nanCount: 0,
infCount: 0
}
const mockHistograms = {
r: new Uint32Array([1]),
g: new Uint32Array([2]),
b: new Uint32Array([3]),
a: new Uint32Array([4]),
luminance: new Uint32Array([5])
}
interface MockTexture {
type: string
image: {
width: number
height: number
data: ArrayLike<number>
}
colorSpace?: string
minFilter?: string
magFilter?: string
needsUpdate?: boolean
dispose: ReturnType<typeof vi.fn>
}
const mocks = vi.hoisted(() => ({
exrLoad: vi.fn(),
exrSetDataType: vi.fn(),
rgbeLoad: vi.fn(),
render: vi.fn(),
setPixelRatio: vi.fn(),
setClearColor: vi.fn(),
setSize: vi.fn(),
updateProjectionMatrix: vi.fn(),
positionSet: vi.fn(),
scaleSet: vi.fn(),
sceneAdd: vi.fn(),
materialDispose: vi.fn(),
geometryDispose: vi.fn(),
viewportObserveResize: vi.fn(),
viewportDisposeRenderer: vi.fn(),
textureDispose: vi.fn(),
raycasterSetFromCamera: vi.fn(),
intersectObject: vi.fn(),
fromHalfFloat: vi.fn((value: number) => value + 0.5),
matrixSet: vi.fn(),
detectGamutFromChromaticities: vi.fn(() => 'Display P3'),
gamutToSrgbMatrix: vi.fn(() => [1, 0, 0, 0, 1, 0, 0, 0, 1]),
computeImageStats: vi.fn(() => mockStats),
computeChannelHistograms: vi.fn(() => mockHistograms),
lastCanvas: undefined as HTMLCanvasElement | undefined
}))
vi.mock('three', () => {
class WebGLRenderer {
domElement: HTMLCanvasElement
outputColorSpace?: string
constructor() {
const canvas = document.createElement('canvas')
canvas.getBoundingClientRect = () =>
({
left: 0,
top: 0,
width: 100,
height: 100,
right: 100,
bottom: 100,
x: 0,
y: 0,
toJSON: () => ({})
}) satisfies DOMRect
this.domElement = canvas
mocks.lastCanvas = canvas
}
setPixelRatio(value: number) {
mocks.setPixelRatio(value)
}
setClearColor(color: number, alpha: number) {
mocks.setClearColor(color, alpha)
}
setSize(width: number, height: number, updateStyle: boolean) {
mocks.setSize(width, height, updateStyle)
}
render(scene: unknown, camera: unknown) {
mocks.render(scene, camera)
}
}
class Scene {
add(mesh: unknown) {
mocks.sceneAdd(mesh)
}
}
class OrthographicCamera {
left = -1
right = 1
top = 1
bottom = -1
zoom = 1
position = {
x: 0,
y: 0,
z: 1,
set: (x: number, y: number, z: number) => {
this.position.x = x
this.position.y = y
this.position.z = z
mocks.positionSet(x, y, z)
}
}
updateProjectionMatrix() {
mocks.updateProjectionMatrix()
}
}
class Matrix3 {
set(...values: number[]) {
mocks.matrixSet(values)
}
}
class ShaderMaterial {
uniforms: Record<string, { value: unknown }>
constructor(options: { uniforms: Record<string, { value: unknown }> }) {
this.uniforms = options.uniforms
}
dispose() {
mocks.materialDispose()
}
}
class Mesh {
scale = { set: mocks.scaleSet }
geometry = { dispose: mocks.geometryDispose }
constructor(
readonly geometryInput: unknown,
readonly materialInput: unknown
) {}
}
class PlaneGeometry {
constructor(
readonly width: number,
readonly height: number
) {}
}
class Raycaster {
setFromCamera(pointer: unknown, camera: unknown) {
mocks.raycasterSetFromCamera(pointer, camera)
}
intersectObject(mesh: unknown) {
return mocks.intersectObject(mesh)
}
}
class Vector2 {
constructor(
public x = 0,
public y = 0
) {}
}
return {
WebGLRenderer,
Scene,
OrthographicCamera,
Matrix3,
ShaderMaterial,
Mesh,
PlaneGeometry,
Raycaster,
Vector2,
DataUtils: { fromHalfFloat: mocks.fromHalfFloat },
MathUtils: {
clamp: (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value))
},
FloatType: 'FloatType',
HalfFloatType: 'HalfFloatType',
LinearSRGBColorSpace: 'LinearSRGBColorSpace',
LinearFilter: 'LinearFilter',
GLSL3: 'GLSL3'
}
})
vi.mock('three/examples/jsm/loaders/EXRLoader', () => ({
EXRLoader: class {
setDataType(type: string) {
mocks.exrSetDataType(type)
}
load(
url: string,
onLoad: (texture: MockTexture, textureData?: unknown) => void,
onProgress: unknown,
onError: (error: unknown) => void
) {
mocks.exrLoad(url, onLoad, onProgress, onError)
}
}
}))
vi.mock('three/examples/jsm/loaders/RGBELoader', () => ({
RGBELoader: class {
load(
url: string,
onLoad: (texture: MockTexture, textureData?: unknown) => void,
onProgress: unknown,
onError: (error: unknown) => void
) {
mocks.rgbeLoad(url, onLoad, onProgress, onError)
}
}
}))
vi.mock('@/renderer/three/WebGLViewport', () => ({
WebGLViewport: class {
constructor(readonly renderer: unknown) {}
observeResize(container: HTMLElement, resize: () => void) {
mocks.viewportObserveResize(container, resize)
}
disposeRenderer() {
mocks.viewportDisposeRenderer()
}
}
}))
vi.mock('@/renderer/hdr/colorGamut', () => ({
detectGamutFromChromaticities: mocks.detectGamutFromChromaticities,
gamutToSrgbMatrix: mocks.gamutToSrgbMatrix
}))
vi.mock('@/renderer/hdr/hdrStats', () => ({
computeImageStats: mocks.computeImageStats,
computeChannelHistograms: mocks.computeChannelHistograms
}))
import { CHANNEL_MODES, useHdrViewer } from './useHdrViewer'
type HdrViewer = ReturnType<typeof useHdrViewer>
let mountedApps: App[] = []
function createViewer(): HdrViewer {
let viewer: HdrViewer | undefined
const app = createApp(
defineComponent({
setup() {
viewer = useHdrViewer()
return () => null
}
})
)
app.mount(document.createElement('div'))
mountedApps.push(app)
if (!viewer) throw new Error('Expected useHdrViewer to initialize')
return viewer
}
function makeTexture(
data: ArrayLike<number> = [0, 0.25, 0.5, 1, 1, 2, 3, 4],
width = 2,
height = 1,
type = 'FloatType'
): MockTexture {
return {
type,
image: { width, height, data },
dispose: mocks.textureDispose
}
}
function makeContainer(): HTMLElement {
const container = document.createElement('div')
Object.defineProperty(container, 'clientWidth', { value: 200 })
Object.defineProperty(container, 'clientHeight', { value: 100 })
return container
}
describe('useHdrViewer', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(0)
return 1
})
mocks.lastCanvas = undefined
mocks.exrLoad.mockImplementation(
(
_url: string,
onLoad: (texture: MockTexture, textureData?: unknown) => void
) => onLoad(makeTexture())
)
mocks.rgbeLoad.mockImplementation(
(
_url: string,
onLoad: (texture: MockTexture, textureData?: unknown) => void
) => onLoad(makeTexture(), { header: { chromaticities: {} } })
)
mocks.intersectObject.mockReturnValue([])
})
afterEach(() => {
for (const app of mountedApps) app.unmount()
mountedApps = []
vi.unstubAllGlobals()
})
it('exposes all channel modes', () => {
expect(CHANNEL_MODES).toEqual(['rgb', 'r', 'g', 'b', 'a', 'luminance'])
})
it('mounts hdr textures through the RGBE loader and exposes image metadata', async () => {
const viewer = createViewer()
const container = makeContainer()
await viewer.mount(container, '/api/view?filename=scene.hdr')
expect(mocks.rgbeLoad).toHaveBeenCalledWith(
'/api/view?filename=scene.hdr',
expect.any(Function),
undefined,
expect.any(Function)
)
expect(mocks.exrSetDataType).not.toHaveBeenCalled()
expect(viewer.loading.value).toBe(false)
expect(viewer.error.value).toBeNull()
expect(viewer.gamut.value).toBe('Display P3')
expect(viewer.dimensions.value).toBe('2 x 1')
expect(viewer.stats.value).toEqual(mockStats)
expect(viewer.histogram.value).toBe(mockHistograms.luminance)
viewer.channel.value = 'g'
await nextTick()
expect(viewer.histogram.value).toBe(mockHistograms.g)
expect(container.contains(mocks.lastCanvas!)).toBe(true)
})
it('selects histograms for every channel mode', async () => {
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=scene.hdr')
for (const [mode, histogram] of [
['r', mockHistograms.r],
['g', mockHistograms.g],
['b', mockHistograms.b],
['a', mockHistograms.a],
['rgb', mockHistograms.luminance],
['luminance', mockHistograms.luminance]
] as const) {
viewer.channel.value = mode
await nextTick()
expect(viewer.histogram.value).toBe(histogram)
}
})
it('loads exr textures with float data and reads hovered pixels', async () => {
const data = [0, 1, 2, 3, 4, 5, 6, 7]
mocks.exrLoad.mockImplementation(
(
_url: string,
onLoad: (texture: MockTexture, textureData?: unknown) => void
) => onLoad(makeTexture(data, 2, 1, 'HalfFloatType'))
)
mocks.intersectObject.mockReturnValue([{ uv: { x: 0.75, y: 0.25 } }])
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
mocks.lastCanvas!.dispatchEvent(
new PointerEvent('pointermove', { clientX: 75, clientY: 75 })
)
expect(mocks.exrSetDataType).toHaveBeenCalledWith('FloatType')
expect(mocks.fromHalfFloat).toHaveBeenCalled()
expect(viewer.pixel.value).toEqual({
x: 1,
y: 0,
r: 4.5,
g: 5.5,
b: 6.5,
a: 7.5
})
})
it('reads three-channel pixels without alpha', async () => {
mocks.exrLoad.mockImplementation(
(
_url: string,
onLoad: (texture: MockTexture, textureData?: unknown) => void
) => onLoad(makeTexture([0, 1, 2, 3, 4, 5], 2, 1))
)
mocks.intersectObject.mockReturnValue([{ uv: { x: 0.75, y: 0.25 } }])
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
mocks.lastCanvas!.dispatchEvent(
new PointerEvent('pointermove', { clientX: 75, clientY: 75 })
)
expect(viewer.pixel.value).toEqual({
x: 1,
y: 0,
r: 3,
g: 4,
b: 5,
a: null
})
})
it('clears the hovered pixel when the pointer leaves or misses the mesh', async () => {
mocks.intersectObject.mockReturnValueOnce([{ uv: { x: 0, y: 0 } }])
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
mocks.lastCanvas!.dispatchEvent(new PointerEvent('pointermove'))
expect(viewer.pixel.value).not.toBeNull()
mocks.lastCanvas!.dispatchEvent(new PointerEvent('pointerleave'))
expect(viewer.pixel.value).toBeNull()
mocks.lastCanvas!.dispatchEvent(new PointerEvent('pointermove'))
expect(viewer.pixel.value).toBeNull()
})
it('normalizes exposure and disposes renderer resources', async () => {
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
viewer.normalizeExposure()
viewer.dispose()
expect(viewer.exposureStops.value).toBe(-2)
expect(mocks.viewportDisposeRenderer).toHaveBeenCalled()
expect(mocks.textureDispose).toHaveBeenCalled()
expect(mocks.materialDispose).toHaveBeenCalled()
expect(mocks.geometryDispose).toHaveBeenCalled()
})
it('handles no-op viewer actions before mounting', () => {
const viewer = createViewer()
viewer.fitView()
viewer.normalizeExposure()
viewer.dispose()
expect(viewer.exposureStops.value).toBe(0)
expect(mocks.viewportDisposeRenderer).not.toHaveBeenCalled()
})
it('leaves sample-derived state empty when texture data is missing', async () => {
mocks.exrLoad.mockImplementation(
(
_url: string,
onLoad: (texture: MockTexture, textureData?: unknown) => void
) =>
onLoad({
...makeTexture(),
image: {
width: 2,
height: 1,
data: undefined as unknown as ArrayLike<number>
}
})
)
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
viewer.normalizeExposure()
expect(viewer.dimensions.value).toBe('2 x 1')
expect(viewer.stats.value).toBeNull()
expect(viewer.histogram.value).toBeNull()
expect(viewer.exposureStops.value).toBe(0)
})
it('disposes textures that finish loading after viewer disposal', async () => {
let resolveLoad: (
texture: MockTexture,
textureData?: unknown
) => void = () => {}
mocks.exrLoad.mockImplementation(
(
_url: string,
onLoad: (texture: MockTexture, textureData?: unknown) => void
) => {
resolveLoad = onLoad
}
)
const viewer = createViewer()
const mounting = viewer.mount(
makeContainer(),
'/api/view?filename=scene.exr'
)
viewer.dispose()
resolveLoad(makeTexture())
await mounting
expect(mocks.textureDispose).toHaveBeenCalled()
})
it('reports loader errors and clears loading state', async () => {
mocks.exrLoad.mockImplementation(
(
_url: string,
_onLoad: (texture: MockTexture, textureData?: unknown) => void,
_onProgress: unknown,
onError: (error: unknown) => void
) => onError(new Error('load failed'))
)
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=broken.exr')
expect(viewer.error.value).toBe('load failed')
expect(viewer.loading.value).toBe(false)
expect(mocks.viewportDisposeRenderer).toHaveBeenCalled()
})
it('reports string loader errors', async () => {
mocks.exrLoad.mockImplementation(
(
_url: string,
_onLoad: (texture: MockTexture, textureData?: unknown) => void,
_onProgress: unknown,
onError: (error: unknown) => void
) => onError('load failed')
)
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=broken.exr')
expect(viewer.error.value).toBe('load failed')
})
it('zooms with the wheel and pans while dragging', async () => {
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
mocks.lastCanvas!.dispatchEvent(new WheelEvent('wheel', { deltaY: -1000 }))
mocks.lastCanvas!.dispatchEvent(
new PointerEvent('pointerdown', { clientX: 10, clientY: 10 })
)
window.dispatchEvent(
new PointerEvent('pointermove', { clientX: 20, clientY: 30 })
)
window.dispatchEvent(new PointerEvent('pointerup'))
expect(mocks.updateProjectionMatrix).toHaveBeenCalled()
expect(mocks.render).toHaveBeenCalled()
})
it('ignores hover sampling while dragging', async () => {
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
mocks.lastCanvas!.dispatchEvent(
new PointerEvent('pointerdown', { clientX: 10, clientY: 10 })
)
mocks.raycasterSetFromCamera.mockClear()
mocks.lastCanvas!.dispatchEvent(
new PointerEvent('pointermove', { clientX: 20, clientY: 20 })
)
window.dispatchEvent(new PointerEvent('pointerup'))
expect(mocks.raycasterSetFromCamera).not.toHaveBeenCalled()
})
})

View File

@@ -1,189 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent } from 'vue'
const mockSettingGet = vi.hoisted(() => vi.fn())
const mockTrackUiButtonClicked = vi.hoisted(() => vi.fn())
const mockReleaseStore = vi.hoisted(() => ({
shouldShowRedDot: { value: false },
initialize: vi.fn()
}))
const mockHelpCenterStore = vi.hoisted(() => ({
isVisible: { value: false },
toggle: vi.fn(),
hide: vi.fn()
}))
const mockConflictDetection = vi.hoisted(() => ({
shouldShowConflictModalAfterUpdate: vi.fn()
}))
const mockShowNodeConflictDialog = vi.hoisted(() => vi.fn())
const mockConflictAcknowledgment = vi.hoisted(() => ({
shouldShowRedDot: { value: false },
markConflictsAsSeen: vi.fn()
}))
vi.mock('pinia', () => ({
storeToRefs: (store: Record<string, unknown>) => store
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: mockSettingGet })
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackUiButtonClicked: mockTrackUiButtonClicked
})
}))
vi.mock('@/platform/updates/common/releaseStore', () => ({
useReleaseStore: () => mockReleaseStore
}))
vi.mock('@/stores/helpCenterStore', () => ({
useHelpCenterStore: () => mockHelpCenterStore
}))
vi.mock(
'@/workbench/extensions/manager/composables/useConflictDetection',
() => ({
useConflictDetection: () => mockConflictDetection
})
)
vi.mock(
'@/workbench/extensions/manager/composables/useNodeConflictDialog',
() => ({
useNodeConflictDialog: () => ({ show: mockShowNodeConflictDialog })
})
)
vi.mock(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
() => ({
useConflictAcknowledgment: () => mockConflictAcknowledgment
})
)
import { useHelpCenter } from './useHelpCenter'
function mountHelpCenter() {
let result: ReturnType<typeof useHelpCenter> | undefined
const app = createApp(
defineComponent({
setup() {
result = useHelpCenter()
return () => null
}
})
)
app.mount(document.createElement('div'))
if (!result) throw new Error('Expected help center composable to initialize')
return { app, result }
}
describe('useHelpCenter', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSettingGet.mockReturnValue('left')
mockReleaseStore.shouldShowRedDot.value = false
mockHelpCenterStore.isVisible.value = false
mockHelpCenterStore.toggle.mockImplementation(() => {
mockHelpCenterStore.isVisible.value = !mockHelpCenterStore.isVisible.value
})
mockHelpCenterStore.hide.mockImplementation(() => {
mockHelpCenterStore.isVisible.value = false
})
mockConflictAcknowledgment.shouldShowRedDot.value = false
mockConflictDetection.shouldShowConflictModalAfterUpdate.mockResolvedValue(
false
)
})
it('initializes releases on mount and exposes store-backed computed state', async () => {
mockReleaseStore.shouldShowRedDot.value = true
const { app, result } = mountHelpCenter()
expect(mockReleaseStore.initialize).toHaveBeenCalledOnce()
expect(result.isHelpCenterVisible.value).toBe(false)
expect(result.shouldShowRedDot.value).toBe(true)
expect(result.sidebarLocation.value).toBe('left')
app.unmount()
})
it('uses the conflict red dot when the release red dot is hidden', () => {
mockConflictAcknowledgment.shouldShowRedDot.value = true
const { app, result } = mountHelpCenter()
expect(result.shouldShowRedDot.value).toBe(true)
app.unmount()
})
it('tracks and toggles help center visibility', () => {
const { app, result } = mountHelpCenter()
result.toggleHelpCenter()
expect(mockTrackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'sidebar_help_center_toggled',
element_group: 'sidebar'
})
expect(mockHelpCenterStore.toggle).toHaveBeenCalledOnce()
expect(mockHelpCenterStore.isVisible.value).toBe(true)
result.closeHelpCenter()
expect(mockHelpCenterStore.hide).toHaveBeenCalledOnce()
expect(mockHelpCenterStore.isVisible.value).toBe(false)
app.unmount()
})
it('opens the conflict modal after the whats-new dialog when needed', async () => {
mockConflictDetection.shouldShowConflictModalAfterUpdate.mockResolvedValue(
true
)
const { app, result } = mountHelpCenter()
await result.handleWhatsNewDismissed()
expect(mockShowNodeConflictDialog).toHaveBeenCalledWith({
showAfterWhatsNew: true,
dialogComponentProps: {
onClose: expect.any(Function)
}
})
const options = mockShowNodeConflictDialog.mock.calls[0][0]
options.dialogComponentProps.onClose()
expect(
mockConflictAcknowledgment.markConflictsAsSeen
).toHaveBeenCalledOnce()
app.unmount()
})
it('does not open the conflict modal when not needed', async () => {
const { app, result } = mountHelpCenter()
await result.handleWhatsNewDismissed()
expect(mockShowNodeConflictDialog).not.toHaveBeenCalled()
app.unmount()
})
it('logs conflict-check failures without throwing', async () => {
const error = new Error('failed')
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockConflictDetection.shouldShowConflictModalAfterUpdate.mockRejectedValue(
error
)
const { app, result } = mountHelpCenter()
await expect(result.handleWhatsNewDismissed()).resolves.toBeUndefined()
expect(consoleError).toHaveBeenCalledWith(
'[HelpCenter] Error checking conflict modal:',
error
)
app.unmount()
})
})

View File

@@ -137,7 +137,7 @@ function mountContainerLayout(
function makePointerEvent(
type: 'pointerdown' | 'pointermove' | 'pointerup',
target: EventTarget,
target: HTMLElement,
clientX: number,
clientY: number
) {
@@ -302,32 +302,6 @@ describe('useImageCrop', () => {
expect(vm.imageUrl).toBeNull()
})
it('returns null when a subgraph output link cannot be resolved', async () => {
const subgraphInput = createMockSubgraphNode([], {
id: 40,
resolveSubgraphOutputLink: vi.fn(() => undefined)
})
const sgCrop = createMockLGraphNode({
id: 2,
getInputNode: vi.fn(() => subgraphInput),
getInputLink: vi.fn(() => ({ origin_slot: 0 })),
isSubgraphNode: () => false
})
mockResolveNode.mockReturnValue(sgCrop)
const vm = await mountHarness()
expect(vm.imageUrl).toBeNull()
})
it('returns null when the source node has no image URLs', async () => {
mockGetNodeImageUrls.mockImplementation((n) =>
n === sourceNode ? [] : null
)
const vm = await mountHarness()
expect(vm.imageUrl).toBeNull()
})
it('resolves image through a subgraph input node', async () => {
const innerSource = createMockLGraphNode({
id: 50,
@@ -366,18 +340,6 @@ describe('useImageCrop', () => {
expect(vm.imageUrl).toBe('https://example.com/b.png')
})
it('keeps loading unchanged when output updates keep the same URL', async () => {
const vm = await mountHarness()
;(vm.handleImageLoad as () => void)()
expect(vm.isLoading).toBe(false)
outputStore.nodeOutputs['touch'] = { updated: true }
await flushTicks()
expect(vm.imageUrl).toBe('https://example.com/a.png')
expect(vm.isLoading).toBe(false)
})
it('updates imageUrl when nodePreviewImages change', async () => {
let url = 'https://example.com/a.png'
mockGetNodeImageUrls.mockImplementation((n) =>
@@ -428,30 +390,6 @@ describe('useImageCrop', () => {
expect(parseFloat(style.height)).toBeCloseTo(80, 1)
})
it('ignores resize observer callbacks before an image is rendered', async () => {
mockGetNodeImageUrls.mockReturnValue(null)
const vm = await mountHarness()
flushResizeObservers()
expect(vm.imageUrl).toBeNull()
expect(vm.cropBoxStyle).toEqual({
left: '38px',
top: '38px',
width: '160px',
height: '120px'
})
})
it('uses default crop dimensions when model dimensions are zero', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)
vm.modelValue = { x: 0, y: 0, width: 0, height: 0 }
expect(vm.cropWidth).toBe(512)
expect(vm.cropHeight).toBe(512)
})
it('exposes eight resize handles when unlocked and four when locked', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)
@@ -485,48 +423,6 @@ describe('useImageCrop', () => {
expect(vm.isLoading).toBe(false)
})
it('uses fallback scale when dragging before image dimensions are known', async () => {
const vm = await mountHarness()
vm.modelValue = { x: 10, y: 10, width: 120, height: 90 }
const captureEl = document.createElement('div')
captureEl.setPointerCapture = vi.fn()
captureEl.releasePointerCapture = vi.fn()
const dragStart = vm.handleDragStart as (e: PointerEvent) => void
const dragMove = vm.handleDragMove as (e: PointerEvent) => void
const dragEnd = vm.handleDragEnd as (e: PointerEvent) => void
dragStart(makePointerEvent('pointerdown', captureEl, 10, 10))
dragMove(makePointerEvent('pointermove', captureEl, 30, 30))
dragEnd(makePointerEvent('pointerup', captureEl, 30, 30))
expect(vm.modelValue.x).toBe(0)
expect(vm.modelValue.y).toBe(0)
})
it('uses fallback scale when rendered container width is missing', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)
mountContainerLayout(vm.$el, 0, 300, 0)
vm.modelValue = { x: 10, y: 10, width: 120, height: 90 }
const captureEl = document.createElement('div')
captureEl.setPointerCapture = vi.fn()
captureEl.releasePointerCapture = vi.fn()
const resizeStart = vm.handleResizeStart as (
e: PointerEvent,
dir: string
) => void
const resizeMove = vm.handleResizeMove as (e: PointerEvent) => void
const resizeEnd = vm.handleResizeEnd as (e: PointerEvent) => void
resizeStart(makePointerEvent('pointerdown', captureEl, 130, 80), 'right')
resizeMove(makePointerEvent('pointermove', captureEl, 150, 80))
resizeEnd(makePointerEvent('pointerup', captureEl, 150, 80))
expect(vm.modelValue.width).toBe(140)
})
it('does not start dragging when there is no image', async () => {
mockGetNodeImageUrls.mockReturnValue(null)
const vm = await mountHarness()
@@ -540,41 +436,6 @@ describe('useImageCrop', () => {
expect(vm.cropX as number).toBe(xBefore)
})
it('ignores drag move and end before dragging starts', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 300)
vm.modelValue = { x: 10, y: 10, width: 120, height: 90 }
const releaseEl = document.createElement('div')
releaseEl.releasePointerCapture = vi.fn()
;(vm.handleDragMove as (e: PointerEvent) => void)(
makePointerEvent('pointermove', releaseEl, 260, 180)
)
;(vm.handleDragEnd as (e: PointerEvent) => void)(
makePointerEvent('pointerup', releaseEl, 260, 180)
)
expect(vm.modelValue).toEqual({ x: 10, y: 10, width: 120, height: 90 })
expect(releaseEl.releasePointerCapture).not.toHaveBeenCalled()
})
it('drags without pointer capture when the event target is not an element', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 300)
vm.modelValue = { x: 10, y: 10, width: 120, height: 90 }
const dragStart = vm.handleDragStart as (e: PointerEvent) => void
const dragMove = vm.handleDragMove as (e: PointerEvent) => void
const dragEnd = vm.handleDragEnd as (e: PointerEvent) => void
dragStart(makePointerEvent('pointerdown', document, 200, 150))
dragMove(makePointerEvent('pointermove', document, 260, 180))
dragEnd(makePointerEvent('pointerup', document, 260, 180))
expect(vm.modelValue.x).toBeGreaterThan(10)
expect(vm.modelValue.y).toBeGreaterThan(10)
})
it('drags the crop box in image space and ends on pointerup', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 300)
@@ -645,62 +506,6 @@ describe('useImageCrop', () => {
expect(vm.modelValue.height).toBeLessThan(200)
})
it('resizes from the left edge and clamps to the image origin', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 500, 500)
vm.modelValue = { x: 50, y: 50, width: 120, height: 100 }
const captureEl = document.createElement('div')
captureEl.setPointerCapture = vi.fn()
captureEl.releasePointerCapture = vi.fn()
const resizeStart = vm.handleResizeStart as (
e: PointerEvent,
dir: string
) => void
const resizeMove = vm.handleResizeMove as (e: PointerEvent) => void
const resizeEnd = vm.handleResizeEnd as (e: PointerEvent) => void
resizeStart(makePointerEvent('pointerdown', captureEl, 100, 120), 'left')
resizeMove(makePointerEvent('pointermove', captureEl, -100, 120))
resizeEnd(makePointerEvent('pointerup', captureEl, -100, 120))
expect(vm.modelValue.x).toBe(0)
expect(vm.modelValue.width).toBeGreaterThan(120)
})
it('ignores resize move and end before resizing starts', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)
vm.modelValue = { x: 40, y: 40, width: 120, height: 120 }
const releaseEl = document.createElement('div')
releaseEl.releasePointerCapture = vi.fn()
;(vm.handleResizeMove as (e: PointerEvent) => void)(
makePointerEvent('pointermove', releaseEl, 360, 360)
)
;(vm.handleResizeEnd as (e: PointerEvent) => void)(
makePointerEvent('pointerup', releaseEl, 360, 360)
)
expect(vm.modelValue).toEqual({ x: 40, y: 40, width: 120, height: 120 })
expect(releaseEl.releasePointerCapture).not.toHaveBeenCalled()
})
it('does not start resizing when there is no image', async () => {
mockGetNodeImageUrls.mockReturnValue(null)
const vm = await mountHarness()
const captureEl = document.createElement('div')
captureEl.setPointerCapture = vi.fn()
;(vm.handleResizeStart as (e: PointerEvent, dir: string) => void)(
makePointerEvent('pointerdown', captureEl, 20, 20),
'right'
)
expect(captureEl.setPointerCapture).not.toHaveBeenCalled()
})
it('applies a preset aspect ratio and clamps height to the image', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 800, 500)
@@ -719,25 +524,6 @@ describe('useImageCrop', () => {
expect(vm.isLockEnabled).toBe(false)
})
it('ignores unknown aspect-ratio presets and unlocks explicit lock changes', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)
vm.modelValue = { x: 0, y: 0, width: 160, height: 120 }
vm.selectedRatio = 'missing'
expect(vm.selectedRatio).toBe('custom')
expect(vm.isLockEnabled).toBe(false)
vm.isLockEnabled = true
expect(vm.selectedRatio).toBe('4:3')
vm.isLockEnabled = true
expect(vm.selectedRatio).toBe('4:3')
vm.isLockEnabled = false
expect(vm.selectedRatio).toBe('custom')
})
it('shows custom in the ratio label when lock does not match a preset', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)
@@ -797,58 +583,6 @@ describe('useImageCrop', () => {
expect(vm.modelValue.y + vm.modelValue.height).toBeLessThanOrEqual(400)
})
it('clamps constrained north-west resize to the image top-left bounds', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)
vm.modelValue = { x: 20, y: 20, width: 80, height: 80 }
vm.isLockEnabled = true
const captureEl = document.createElement('div')
captureEl.setPointerCapture = vi.fn()
captureEl.releasePointerCapture = vi.fn()
const resizeStart = vm.handleResizeStart as (
e: PointerEvent,
dir: string
) => void
const resizeMove = vm.handleResizeMove as (e: PointerEvent) => void
const resizeEnd = vm.handleResizeEnd as (e: PointerEvent) => void
resizeStart(makePointerEvent('pointerdown', captureEl, 40, 40), 'nw')
resizeMove(makePointerEvent('pointermove', captureEl, -200, -200))
resizeEnd(makePointerEvent('pointerup', captureEl, -200, -200))
expect(vm.modelValue.x).toBeGreaterThanOrEqual(0)
expect(vm.modelValue.y).toBeGreaterThanOrEqual(0)
expect(vm.modelValue.width).toBeGreaterThanOrEqual(16)
expect(vm.modelValue.height).toBeGreaterThanOrEqual(16)
})
it('clamps constrained corner resize to minimum dimensions', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)
vm.modelValue = { x: 40, y: 40, width: 160, height: 80 }
vm.isLockEnabled = true
const captureEl = document.createElement('div')
captureEl.setPointerCapture = vi.fn()
captureEl.releasePointerCapture = vi.fn()
const resizeStart = vm.handleResizeStart as (
e: PointerEvent,
dir: string
) => void
const resizeMove = vm.handleResizeMove as (e: PointerEvent) => void
const resizeEnd = vm.handleResizeEnd as (e: PointerEvent) => void
resizeStart(makePointerEvent('pointerdown', captureEl, 200, 120), 'se')
resizeMove(makePointerEvent('pointermove', captureEl, -800, -800))
resizeEnd(makePointerEvent('pointerup', captureEl, -800, -800))
expect(vm.modelValue.width).toBe(32)
expect(vm.modelValue.height).toBe(16)
})
it('ends resize and clears direction on pointerup', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)

View File

@@ -1,134 +0,0 @@
import { createApp, h, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import type { Ref } from 'vue'
type ObserverInit = ConstructorParameters<typeof IntersectionObserver>[1]
type ObserverCallback = ConstructorParameters<typeof IntersectionObserver>[0]
const observers: MockIntersectionObserver[] = []
class MockIntersectionObserver {
readonly callback: ObserverCallback
readonly options?: ObserverInit
readonly observe = vi.fn()
readonly unobserve = vi.fn()
readonly disconnect = vi.fn()
constructor(callback: ObserverCallback, options?: ObserverInit) {
this.callback = callback
this.options = options
observers.push(this)
}
}
function mountObserver(
target: Ref<Element | null>,
callback: IntersectionObserverCallback,
options: Parameters<typeof useIntersectionObserver>[2] = {}
) {
let result: ReturnType<typeof useIntersectionObserver> | undefined
const app = createApp({
setup() {
result = useIntersectionObserver(target, callback, options)
return () => h('div')
}
})
app.mount(document.createElement('div'))
if (!result) throw new Error('useIntersectionObserver did not initialize')
return {
result,
unmount: () => app.unmount()
}
}
beforeEach(() => {
observers.length = 0
Object.defineProperty(window, 'IntersectionObserver', {
configurable: true,
value: MockIntersectionObserver
})
})
describe('useIntersectionObserver', () => {
it('observes the target immediately and updates intersection state', async () => {
const target = ref<Element | null>(document.createElement('div'))
const callback = vi.fn()
const { result, unmount } = mountObserver(target, callback, {
threshold: 0.5
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(result.isSupported).toBe(true)
expect(observers).toHaveLength(1)
expect(observers[0].options).toMatchObject({ threshold: 0.5 })
expect(observers[0].observe).toHaveBeenCalledWith(target.value)
observers[0].callback(
[
{ isIntersecting: false },
{ isIntersecting: true }
] as IntersectionObserverEntry[],
observers[0] as unknown as IntersectionObserver
)
expect(result.isIntersecting.value).toBe(true)
expect(callback).toHaveBeenCalled()
unmount()
expect(observers[0].disconnect).toHaveBeenCalled()
})
it('supports manual observe, unobserve, and cleanup', () => {
const target = ref<Element | null>(document.createElement('div'))
const { result, unmount } = mountObserver(target, vi.fn(), {
immediate: false
})
expect(observers).toHaveLength(0)
result.observe()
expect(observers).toHaveLength(1)
expect(observers[0].observe).toHaveBeenCalledWith(target.value)
result.unobserve()
expect(observers[0].unobserve).toHaveBeenCalledWith(target.value)
result.cleanup()
expect(observers[0].disconnect).toHaveBeenCalled()
unmount()
})
it('does nothing when unsupported or missing a target', () => {
Reflect.deleteProperty(window, 'IntersectionObserver')
const unsupported = mountObserver(
ref(document.createElement('div')),
vi.fn(),
{
immediate: false
}
)
unsupported.result.observe()
expect(unsupported.result.isSupported).toBe(false)
expect(observers).toHaveLength(0)
unsupported.unmount()
Object.defineProperty(window, 'IntersectionObserver', {
configurable: true,
value: MockIntersectionObserver
})
const missingTarget = mountObserver(ref<Element | null>(null), vi.fn(), {
immediate: false
})
missingTarget.result.observe()
expect(observers).toHaveLength(0)
missingTarget.unmount()
})
})

View File

@@ -1,4 +1,3 @@
import { fromAny } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive, ref, shallowRef } from 'vue'
import type { Pinia } from 'pinia'
@@ -15,7 +14,6 @@ import {
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { Size } from '@/lib/litegraph/src/interfaces'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -23,7 +21,6 @@ import type { IWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import {
createMockCanvasPointerEvent,
createMockLGraphNode
@@ -298,19 +295,6 @@ describe('useLoad3d', () => {
expect(mockLoad3d.renderer!.domElement.hidden).toBe(true)
})
it('keeps the renderer visible when collapsed flag is unset', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
delete mockNode.flags.collapsed
mockLoad3d.renderer!.domElement.hidden = true
mockNode.onDrawBackground?.({} as CanvasRenderingContext2D)
expect(mockLoad3d.renderer!.domElement.hidden).toBe(false)
})
it('should initialize without loading model (model loading is handled by Load3DConfiguration)', async () => {
mockNode.widgets!.push({
name: 'model_file',
@@ -347,31 +331,6 @@ describe('useLoad3d', () => {
})
})
it('uses saved config fallbacks for empty render mode and missing light intensity', async () => {
mockNode.properties!['Scene Config'] = {
showGrid: true,
backgroundColor: '#111111',
backgroundImage: '',
backgroundRenderMode: ''
}
mockNode.properties!['Light Config'] = {
hdri: {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
}
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
expect(composable.sceneConfig.value.backgroundRenderMode).toBe('tiled')
expect(composable.lightConfig.value.intensity).toBe(5)
expect(mockLoad3d.setBackgroundRenderMode).toHaveBeenCalledWith('tiled')
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(5)
})
it('should set preview mode when no width/height widgets', async () => {
mockNode.widgets = []
@@ -438,127 +397,6 @@ describe('useLoad3d', () => {
expect.objectContaining({ getZoomScale: expect.any(Function) })
)
})
it('passes live dimension, zoom, and context-menu callbacks to Load3d', async () => {
const originalCanvas = app.canvas
const menuOptions = [{ content: 'Inspect' }]
const getNodeMenuOptions = vi.fn(() => menuOptions)
app.canvas = {
ds: { scale: 2.25 },
getNodeMenuOptions
} as unknown as typeof app.canvas
const contextMenuSpy = vi
.spyOn(LiteGraph, 'ContextMenu')
.mockImplementation(function () {})
try {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const config = vi.mocked(createLoad3d).mock.calls[0][1]!
expect(config.getDimensions?.()).toEqual({ width: 512, height: 512 })
mockNode.widgets![0].value = 640
mockNode.widgets![1].value = 360
expect(config.getDimensions?.()).toEqual({ width: 640, height: 360 })
expect(config.getZoomScale!()).toBe(2.25)
const event = new MouseEvent('contextmenu')
config.onContextMenu!(event)
expect(getNodeMenuOptions).toHaveBeenCalledWith(mockNode)
expect(contextMenuSpy).toHaveBeenCalledWith(menuOptions, {
event,
title: mockNode.type,
extra: mockNode
})
} finally {
app.canvas = originalCanvas
contextMenuSpy.mockRestore()
}
})
it('falls back to zoom scale 1 when the app canvas is unavailable', async () => {
const originalCanvas = app.canvas
app.canvas = undefined as unknown as typeof app.canvas
try {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const config = vi.mocked(createLoad3d).mock.calls[0][1]!
expect(config.getZoomScale!()).toBe(1)
} finally {
app.canvas = originalCanvas
}
})
it('restores and enables a saved HDRI when it loads successfully', async () => {
mockNode.properties!['Light Config'] = {
intensity: 7,
hdri: {
enabled: true,
hdriPath: '3d/env.hdr',
showAsBackground: true,
intensity: 2
}
}
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['3d', 'env.hdr'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/view?filename=env.hdr'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/view?filename=env.hdr'
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
expect(mockLoad3d.loadHDRI).toHaveBeenCalledWith(
'http://localhost/view?filename=env.hdr'
)
expect(composable.lightConfig.value.hdri!.enabled).toBe(true)
expect(mockLoad3d.setHDRIEnabled).toHaveBeenCalledWith(true)
})
it('clears saved HDRI state when restore fails', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockNode.properties!['Light Config'] = {
intensity: 7,
hdri: {
enabled: true,
hdriPath: '3d/missing.hdr',
showAsBackground: true,
intensity: 2
}
}
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'3d',
'missing.hdr'
])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/view?filename=missing.hdr'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/view?filename=missing.hdr'
)
vi.mocked(mockLoad3d.loadHDRI!).mockRejectedValueOnce(
new Error('missing')
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
expect(warnSpy).toHaveBeenCalledWith(
'Failed to restore HDRI:',
expect.any(Error)
)
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('')
expect(composable.lightConfig.value.hdri!.enabled).toBe(false)
warnSpy.mockRestore()
})
})
describe('zoom watcher', () => {
@@ -688,31 +526,6 @@ describe('useLoad3d', () => {
})
describe('configuration watchers', () => {
it('updates refs before Load3d exists without calling scene methods', async () => {
const composable = useLoad3d(mockNode)
composable.sceneConfig.value.backgroundColor = '#abcdef'
composable.sceneConfig.value.backgroundImage = 'pending.png'
composable.sceneConfig.value.backgroundRenderMode = 'panorama'
composable.modelConfig.value.upDirection = '+z'
composable.modelConfig.value.materialMode = 'normal'
composable.modelConfig.value.showSkeleton = true
composable.cameraConfig.value.fov = 55
composable.lightConfig.value.intensity = 9
composable.lightConfig.value.hdri!.intensity = 3
composable.lightConfig.value.hdri!.showAsBackground = true
composable.lightConfig.value.hdri!.enabled = true
await nextTick()
expect(mockLoad3d.setBackgroundColor).not.toHaveBeenCalled()
expect(mockLoad3d.setBackgroundImage).not.toHaveBeenCalled()
expect(mockLoad3d.setBackgroundRenderMode).not.toHaveBeenCalled()
expect(mockLoad3d.setUpDirection).not.toHaveBeenCalled()
expect(mockLoad3d.setMaterialMode).not.toHaveBeenCalled()
expect(mockLoad3d.setShowSkeleton).not.toHaveBeenCalled()
expect(mockLoad3d.setLightIntensity).not.toHaveBeenCalled()
})
it('should update scene config when values change', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
@@ -754,23 +567,20 @@ describe('useLoad3d', () => {
vi.mocked(mockLoad3d.setUpDirection!).mockClear()
vi.mocked(mockLoad3d.setMaterialMode!).mockClear()
vi.mocked(mockLoad3d.setShowSkeleton!).mockClear()
composable.modelConfig.value.upDirection = '+y'
composable.modelConfig.value.materialMode = 'wireframe'
composable.modelConfig.value.showSkeleton = true
await nextTick()
expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('+y')
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
expect(mockLoad3d.setShowSkeleton).toHaveBeenCalledWith(true)
const savedModelConfig = mockNode.properties['Model Config'] as Record<
string,
unknown
>
expect(savedModelConfig.upDirection).toBe('+y')
expect(savedModelConfig.materialMode).toBe('wireframe')
expect(savedModelConfig.showSkeleton).toBe(true)
expect(savedModelConfig.showSkeleton).toBe(false)
})
it('should update camera config when values change', async () => {
@@ -1096,68 +906,6 @@ describe('useLoad3d', () => {
expect(composable.modelConfig.value.materialMode).toBe('wireframe')
})
it('applies scalar Load3d events to config refs', async () => {
const handlers: Record<string, (value: unknown) => void> = {}
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
handlers[event] = handler as (value: unknown) => void
}
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
handlers.backgroundColorChange('#123456')
handlers.backgroundRenderModeChange('panorama')
handlers.lightIntensityChange(8)
handlers.fovChange(60)
handlers.cameraTypeChange('orthographic')
handlers.showGridChange(false)
handlers.upDirectionChange('-z')
handlers.backgroundImageChange('scene.jpg')
expect(composable.sceneConfig.value).toMatchObject({
backgroundColor: '#123456',
backgroundRenderMode: 'panorama',
showGrid: false,
backgroundImage: 'scene.jpg'
})
expect(composable.lightConfig.value.intensity).toBe(8)
expect(composable.cameraConfig.value).toMatchObject({
fov: 60,
cameraType: 'orthographic'
})
expect(composable.modelConfig.value.upDirection).toBe('-z')
})
it('should handle background image loading events', async () => {
let startHandler: (() => void) | undefined
let endHandler: (() => void) | undefined
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
if (event === 'backgroundImageLoadingStart') {
startHandler = handler as () => void
} else if (event === 'backgroundImageLoadingEnd') {
endHandler = handler as () => void
}
}
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
startHandler?.()
expect(composable.loading.value).toBe(true)
expect(composable.loadingMessage.value).toBe(
'load3d.loadingBackgroundImage'
)
endHandler?.()
expect(composable.loading.value).toBe(false)
expect(composable.loadingMessage.value).toBe('')
})
it('should handle loading events', async () => {
let modelLoadingStartHandler: (() => void) | undefined
let modelLoadingEndHandler: (() => void) | undefined
@@ -1186,70 +934,6 @@ describe('useLoad3d', () => {
expect(composable.loadingMessage.value).toBe('')
})
it('handles stale modelLoadingEnd after cleanup with fallback model state', async () => {
let modelLoadingEndHandler: (() => void) | undefined
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
if (event === 'modelLoadingEnd') {
modelLoadingEndHandler = handler as () => void
}
}
)
vi.mocked(mockLoad3d.isSplatModel!).mockReturnValue(true)
vi.mocked(mockLoad3d.isPlyModel!).mockReturnValue(true)
vi.mocked(mockLoad3d.getSourceFormat!).mockReturnValue('splat')
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
composable.cleanup()
modelLoadingEndHandler?.()
expect(composable.isSplatModel.value).toBe(false)
expect(composable.isPlyModel.value).toBe(false)
expect(composable.sourceFormat.value).toBeNull()
expect(composable.canFitToViewer.value).toBe(true)
expect(composable.canUseGizmo.value).toBe(true)
expect(composable.canUseLighting.value).toBe(true)
expect(composable.canExport.value).toBe(true)
})
it('uses fallback model capabilities when Load3d returns none', async () => {
let modelLoadingEndHandler: (() => void) | undefined
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
if (event === 'modelLoadingEnd') {
modelLoadingEndHandler = handler as () => void
}
}
)
vi.mocked(mockLoad3d.isSplatModel!).mockReturnValue(true)
vi.mocked(mockLoad3d.getSourceFormat!).mockReturnValue('splat')
vi.mocked(mockLoad3d.getCurrentModelCapabilities!).mockReturnValue(
fromAny(undefined)
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
modelLoadingEndHandler?.()
expect(composable.isSplatModel.value).toBe(true)
expect(composable.sourceFormat.value).toBe('splat')
expect(composable.canCenterCameraOnModel.value).toBe(true)
expect(composable.canFitToViewer.value).toBe(true)
expect(composable.canUseGizmo.value).toBe(true)
expect(composable.canUseLighting.value).toBe(true)
expect(composable.canExport.value).toBe(true)
expect(composable.materialModes.value).toEqual([
'original',
'normal',
'wireframe'
])
})
it('should handle recordingStatusChange event', async () => {
let recordingStatusHandler: ((status: boolean) => void) | undefined
@@ -1272,70 +956,6 @@ describe('useLoad3d', () => {
expect(composable.recordingDuration.value).toBe(10)
expect(composable.hasRecording.value).toBe(true)
})
it('should handle recordingStatusChange true without recording duration update', async () => {
let recordingStatusHandler: ((status: boolean) => void) | undefined
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
if (event === 'recordingStatusChange') {
recordingStatusHandler = handler as (status: boolean) => void
}
}
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
recordingStatusHandler?.(true)
expect(composable.isRecording.value).toBe(true)
expect(mockLoad3d.getRecordingDuration).not.toHaveBeenCalled()
})
it('should use the default export loading message when none is provided', async () => {
let exportLoadingStartHandler: ((message: string) => void) | undefined
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
if (event === 'exportLoadingStart') {
exportLoadingStartHandler = handler as (message: string) => void
}
}
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
exportLoadingStartHandler?.('')
expect(composable.loading.value).toBe(true)
expect(composable.loadingMessage.value).toBe('load3d.exportingModel')
})
it('handles export completion and animation list events', async () => {
const handlers: Record<string, (value?: unknown) => void> = {}
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
handlers[event] = handler as (value?: unknown) => void
}
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
handlers.exportLoadingStart('Exporting GLB')
expect(composable.loading.value).toBe(true)
expect(composable.loadingMessage.value).toBe('Exporting GLB')
handlers.exportLoadingEnd()
expect(composable.loading.value).toBe(false)
expect(composable.loadingMessage.value).toBe('')
const animations = [{ name: 'Idle', index: 0 }]
handlers.animationListChange(animations)
expect(composable.animations.value).toEqual(animations)
})
})
describe('cleanup', () => {
@@ -1357,19 +977,6 @@ describe('useLoad3d', () => {
expect(() => composable.cleanup()).not.toThrow()
})
it('stops cleanup early when the node ref is already cleared', async () => {
const nodeRef = shallowRef<LGraphNode | null>(mockNode)
const composable = useLoad3d(nodeRef)
await composable.initializeLoad3d(document.createElement('div'))
nodeRef.value = null
composable.cleanup()
expect(mockLoad3d.removeEventListener).toHaveBeenCalled()
expect(mockLoad3d.remove).not.toHaveBeenCalled()
expect(nodeToLoad3dMap.has(mockNode)).toBe(true)
})
})
describe('handleModelDrop', () => {
@@ -1401,62 +1008,6 @@ describe('useLoad3d', () => {
)
})
it('updates model_file widget values after a successful model drop', async () => {
const modelWidget = {
name: 'model_file',
value: '',
options: { values: ['existing.glb'] }
} as unknown as IWidget
mockNode.widgets!.push(modelWidget)
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb')
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'uploaded',
'model.glb'
])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/uploaded/model.glb'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/api/view/uploaded/model.glb'
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
await composable.handleModelDrop(
new File([''], 'model.glb', { type: 'model/gltf-binary' })
)
expect(modelWidget.value).toBe('uploaded/model.glb')
expect(modelWidget.options.values).toEqual([
'existing.glb',
'uploaded/model.glb'
])
})
it('uses output resource URLs when dropping a model into preview mode', async () => {
mockNode.widgets = []
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb')
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'uploaded',
'model.glb'
])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/uploaded/model.glb'
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
await composable.handleModelDrop(
new File([''], 'model.glb', { type: 'model/gltf-binary' })
)
expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith(
'uploaded',
'model.glb',
'output'
)
})
it('should use resource folder for upload subfolder', async () => {
mockNode.properties['Resource Folder'] = 'subfolder'
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb')
@@ -1496,69 +1047,6 @@ describe('useLoad3d', () => {
'toastMessages.no3dScene'
)
})
it('returns before upload when the node ref is cleared after initialization', async () => {
const nodeRef = shallowRef<LGraphNode | null>(mockNode)
const composable = useLoad3d(nodeRef)
await composable.initializeLoad3d(document.createElement('div'))
nodeRef.value = null
await composable.handleModelDrop(
new File([''], 'model.glb', { type: 'model/gltf-binary' })
)
expect(Load3dUtils.uploadFile).not.toHaveBeenCalled()
})
it('shows an upload failure alert when model upload returns no path', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('')
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
await composable.handleModelDrop(
new File([''], 'model.glb', { type: 'model/gltf-binary' })
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.fileUploadFailed'
)
expect(mockLoad3d.loadModel).not.toHaveBeenCalled()
expect(composable.loading.value).toBe(false)
expect(composable.loadingMessage.value).toBe('')
})
it('shows a load failure alert when model loading throws', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb')
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'uploaded',
'model.glb'
])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/uploaded/model.glb'
)
vi.mocked(mockLoad3d.loadModel!).mockRejectedValueOnce(
new Error('bad model')
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
await composable.handleModelDrop(
new File([''], 'model.glb', { type: 'model/gltf-binary' })
)
expect(errorSpy).toHaveBeenCalledWith(
'Model drop failed:',
expect.any(Error)
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToLoadModel'
)
expect(composable.loading.value).toBe(false)
expect(composable.loadingMessage.value).toBe('')
errorSpy.mockRestore()
})
})
describe('hdri controls', () => {
@@ -1604,40 +1092,6 @@ describe('useLoad3d', () => {
expect(mockLoad3d.setHDRIIntensity).toHaveBeenCalledWith(2.5)
})
it('restores non-HDRI light intensity when HDRI is disabled', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.lightConfig.value.intensity = 8
await nextTick()
composable.lightConfig.value = {
...composable.lightConfig.value,
hdri: { ...composable.lightConfig.value.hdri!, enabled: true }
}
await nextTick()
composable.lightConfig.value.intensity = 1
await nextTick()
composable.lightConfig.value = {
...composable.lightConfig.value,
hdri: { ...composable.lightConfig.value.hdri!, enabled: false }
}
await nextTick()
expect(composable.lightConfig.value.intensity).toBe(8)
expect(mockLoad3d.setHDRIEnabled).toHaveBeenLastCalledWith(false)
})
it('should ignore HDRI updates before Load3d is initialized', async () => {
const composable = useLoad3d(mockNode)
await composable.handleHDRIFileUpdate(
new File([''], 'env.hdr', { type: 'image/x-hdr' })
)
expect(Load3dUtils.uploadFile).not.toHaveBeenCalled()
})
it('should upload file, load HDRI and update hdriConfig', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('3d/env.hdr')
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['3d', 'env.hdr'])
@@ -1663,100 +1117,6 @@ describe('useLoad3d', () => {
expect(composable.lightConfig.value.hdri!.enabled).toBe(true)
})
it('skips HDRI loading when the Load3d instance changes after upload', async () => {
const composable = useLoad3d(mockNode)
vi.mocked(Load3dUtils.uploadFile).mockImplementation(async () => {
composable.cleanup()
return '3d/env.hdr'
})
await composable.initializeLoad3d(document.createElement('div'))
await composable.handleHDRIFileUpdate(
new File([''], 'env.hdr', { type: 'image/x-hdr' })
)
expect(mockLoad3d.loadHDRI).not.toHaveBeenCalled()
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('')
})
it('skips HDRI state updates when the Load3d instance changes after load', async () => {
const composable = useLoad3d(mockNode)
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('3d/env.hdr')
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['3d', 'env.hdr'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/view?filename=env.hdr'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/view?filename=env.hdr'
)
vi.mocked(mockLoad3d.loadHDRI!).mockImplementation(async () => {
composable.cleanup()
})
await composable.initializeLoad3d(document.createElement('div'))
await composable.handleHDRIFileUpdate(
new File([''], 'env.hdr', { type: 'image/x-hdr' })
)
expect(mockLoad3d.loadHDRI).toHaveBeenCalled()
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('')
expect(composable.loading.value).toBe(false)
expect(composable.loadingMessage.value).toBe('')
})
it('should leave HDRI state unchanged when upload returns no path', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('')
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
await composable.handleHDRIFileUpdate(
new File([''], 'env.hdr', { type: 'image/x-hdr' })
)
expect(mockLoad3d.loadHDRI).not.toHaveBeenCalled()
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('')
expect(composable.loading.value).toBe(false)
expect(composable.loadingMessage.value).toBe('')
})
it('should clear HDRI and show an alert when HDRI loading fails', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('3d/bad.hdr')
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['3d', 'bad.hdr'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/view?filename=bad.hdr'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/view?filename=bad.hdr'
)
vi.mocked(mockLoad3d.loadHDRI!).mockRejectedValueOnce(
new Error('bad hdri')
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
await composable.handleHDRIFileUpdate(
new File([''], 'bad.hdr', { type: 'image/x-hdr' })
)
expect(errorSpy).toHaveBeenCalledWith(
'Failed to load HDRI:',
expect.any(Error)
)
expect(mockLoad3d.clearHDRI).toHaveBeenCalled()
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('')
expect(composable.lightConfig.value.hdri!.enabled).toBe(false)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToLoadHDRI'
)
expect(composable.loading.value).toBe(false)
expect(composable.loadingMessage.value).toBe('')
errorSpy.mockRestore()
})
it('should clear HDRI when file is null', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
@@ -1820,23 +1180,6 @@ describe('useLoad3d', () => {
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('existing.jpg')
})
it('skips background color updates while HDRI is enabled', async () => {
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
composable.lightConfig.value = {
...composable.lightConfig.value,
hdri: { ...composable.lightConfig.value.hdri!, enabled: true }
}
await nextTick()
vi.mocked(mockLoad3d.setBackgroundColor!).mockClear()
composable.sceneConfig.value.backgroundColor = '#ffffff'
await nextTick()
expect(mockLoad3d.setBackgroundColor).not.toHaveBeenCalled()
})
})
describe('gizmo controls', () => {
@@ -1876,37 +1219,6 @@ describe('useLoad3d', () => {
})
})
it('applies restored gizmo config after model loading ends', async () => {
;(mockNode.properties!['Model Config'] as Record<string, unknown>).gizmo =
{
enabled: true,
mode: 'rotate',
position: { x: 1, y: 2, z: 3 },
rotation: { x: 0.1, y: 0.2, z: 0.3 },
scale: { x: 2, y: 2, z: 2 }
}
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
vi.mocked(mockLoad3d.applyGizmoTransform!).mockClear()
vi.mocked(mockLoad3d.setGizmoEnabled!).mockClear()
vi.mocked(mockLoad3d.setGizmoMode!).mockClear()
const loadingEndCall = vi
.mocked(mockLoad3d.addEventListener!)
.mock.calls.find(([event]) => event === 'modelLoadingEnd')
const loadingEndHandler = loadingEndCall![1] as () => void
loadingEndHandler()
expect(mockLoad3d.applyGizmoTransform).toHaveBeenCalledWith(
{ x: 1, y: 2, z: 3 },
{ x: 0.1, y: 0.2, z: 0.3 },
{ x: 2, y: 2, z: 2 }
)
expect(mockLoad3d.setGizmoEnabled).toHaveBeenCalledWith(true)
expect(mockLoad3d.setGizmoMode).toHaveBeenCalledWith('rotate')
})
it('should add default gizmo config when missing from saved config', async () => {
mockNode.properties!['Model Config'] = {
upDirection: 'original',
@@ -2269,29 +1581,6 @@ describe('useLoad3d', () => {
)
})
it('captures thumbnail from an image widget when no model_file widget exists', async () => {
const { isAssetPreviewSupported, persistThumbnail } =
await import('@/platform/assets/utils/assetPreviewUtil')
vi.mocked(isAssetPreviewSupported).mockReturnValue(true)
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'',
'preview.png'
] as unknown as ReturnType<typeof Load3dUtils.splitFilePath>)
mockNode.widgets = [
{ name: 'image', value: 'preview.png' } as unknown as IWidget
]
const { handler } = await getModelReadyHandler()
handler()
await new Promise((r) => setTimeout(r, 0))
expect(mockLoad3d.captureThumbnail).toHaveBeenCalledWith(256, 256)
expect(persistThumbnail).toHaveBeenCalledWith(
'preview.png',
expect.any(Blob)
)
})
it('skips persistence when the model widget has no value', async () => {
const { isAssetPreviewSupported, persistThumbnail } =
await import('@/platform/assets/utils/assetPreviewUtil')
@@ -2550,29 +1839,6 @@ describe('useLoad3d', () => {
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('cameraChanged creates camera properties when the node has none', async () => {
let cameraChangedHandler: ((state: unknown) => void) | undefined
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
if (event === 'cameraChanged') {
cameraChangedHandler = handler as (state: unknown) => void
}
}
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
mockNode.properties = undefined as unknown as LGraphNode['properties']
cameraChangedHandler!({ position: { x: 1, y: 2, z: 3 } })
expect(mockNode.properties['Camera Config']).toEqual({
cameraType: 'perspective',
fov: 75,
state: { position: { x: 1, y: 2, z: 3 } }
})
})
it('handleStopRecording marks dirty when a recording was produced', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
@@ -2643,27 +1909,6 @@ describe('useLoad3d', () => {
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('handleCenterCameraOnModel is a no-op before initialization', () => {
const composable = useLoad3d(mockNode)
composable.handleCenterCameraOnModel()
expect(mockLoad3d.centerCameraOnModel).not.toHaveBeenCalled()
})
it('handleSeek is a no-op before animation duration is known', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
composable.handleSeek(50)
expect(mockLoad3d.setAnimationTime).not.toHaveBeenCalled()
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
})
it('handleSeek marks dirty when the animation has a duration', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')

View File

@@ -1,4 +1,3 @@
import { fromAny } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -129,9 +128,6 @@ describe('useLoad3dViewer', () => {
setCameraState: vi.fn(),
addEventListener: vi.fn(),
hasAnimations: vi.fn().mockReturnValue(false),
toggleAnimation: vi.fn(),
setAnimationSpeed: vi.fn(),
updateSelectedAnimation: vi.fn(),
isSplatModel: vi.fn().mockReturnValue(false),
isPlyModel: vi.fn().mockReturnValue(false),
getSourceFormat: vi.fn().mockReturnValue(null),
@@ -151,12 +147,7 @@ describe('useLoad3dViewer', () => {
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}),
setAnimationTime: vi.fn(),
getAnimationDuration: vi.fn().mockReturnValue(12),
animationManager: {
animationClips: []
} as Partial<Load3d['animationManager']> as Load3d['animationManager']
})
}
mockSourceLoad3d = {
@@ -178,18 +169,6 @@ describe('useLoad3dViewer', () => {
currentUpDirection: 'original',
materialMode: 'original'
} as Load3d['modelManager'],
isSplatModel: vi.fn().mockReturnValue(false),
isPlyModel: vi.fn().mockReturnValue(false),
getSourceFormat: vi.fn().mockReturnValue(null),
getCurrentModelCapabilities: vi.fn().mockReturnValue({
fitToViewer: true,
requiresMaterialRebuild: false,
gizmoTransform: true,
lighting: true,
exportable: true,
materialModes: ['original', 'normal', 'wireframe'],
fitTargetSize: 5
}),
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
setBackgroundRenderMode: vi.fn(),
forceRender: vi.fn()
@@ -269,91 +248,6 @@ describe('useLoad3dViewer', () => {
expect(viewer.hasBackgroundImage.value).toBe(true)
})
it('passes target dimensions when width and height widgets are present', async () => {
mockNode.widgets = [
{ name: 'width', value: 640 },
{ name: 'height', value: 360 }
] as LGraphNode['widgets']
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
expect(createLoad3d).toHaveBeenCalledWith(
containerRef,
expect.objectContaining({
width: 640,
height: 360,
isViewerMode: true,
getDimensions: expect.any(Function)
})
)
const config = vi.mocked(createLoad3d).mock.calls[0][1]!
mockNode.widgets![0].value = 800
mockNode.widgets![1].value = 450
expect(config.getDimensions?.()).toEqual({ width: 800, height: 450 })
})
it('falls back to source values when saved configs are empty', async () => {
mockNode.properties!['Scene Config'] = {
backgroundColor: '',
showGrid: undefined,
backgroundImage: '',
backgroundRenderMode: ''
}
mockNode.properties!['Camera Config'] = {
cameraType: undefined,
fov: undefined
}
mockNode.properties!['Model Config'] = {
upDirection: undefined,
materialMode: undefined
}
mockSourceLoad3d.sceneManager!.backgroundRenderMode = 'panorama'
mockSourceLoad3d.sceneManager!.gridHelper.visible = false
mockSourceLoad3d.sceneManager!.currentBackgroundColor = '#111111'
vi.mocked(mockSourceLoad3d.getCurrentCameraType!).mockReturnValue(
'orthographic'
)
mockSourceLoad3d.cameraManager!.perspectiveCamera.fov = 35
mockSourceLoad3d.modelManager!.currentUpDirection = '+y'
mockSourceLoad3d.modelManager!.materialMode = 'normal'
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
expect(viewer.backgroundColor.value).toBe('#111111')
expect(viewer.showGrid.value).toBe(false)
expect(viewer.backgroundRenderMode.value).toBe('panorama')
expect(viewer.cameraType.value).toBe('orthographic')
expect(viewer.fov.value).toBe(35)
expect(viewer.upDirection.value).toBe('+y')
expect(viewer.materialMode.value).toBe('normal')
})
it('initializes animation state from existing clips', async () => {
vi.mocked(mockLoad3d.hasAnimations!).mockReturnValue(true)
mockLoad3d.animationManager = {
animationClips: [{ name: 'Walk' }, { name: '' }]
} as Partial<Load3d['animationManager']> as Load3d['animationManager']
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
expect(viewer.animations.value).toEqual([
{ name: 'Walk', index: 0 },
{ name: 'Animation 2', index: 1 }
])
expect(viewer.animationDuration.value).toBe(12)
})
it('should handle initialization errors', async () => {
vi.mocked(createLoad3d).mockImplementationOnce(() => {
throw new Error('Load3d creation failed')
@@ -371,41 +265,6 @@ describe('useLoad3dViewer', () => {
})
describe('error handling', () => {
it('ignores watcher changes before Load3d is initialized', async () => {
const viewer = useLoad3dViewer(mockNode)
viewer.backgroundColor.value = '#ff0000'
viewer.showGrid.value = false
viewer.cameraType.value = 'orthographic'
viewer.fov.value = 60
viewer.lightIntensity.value = 2
viewer.backgroundImage.value = 'bg.jpg'
viewer.backgroundRenderMode.value = 'panorama'
viewer.upDirection.value = '+y'
viewer.materialMode.value = 'normal'
viewer.playing.value = true
viewer.selectedSpeed.value = 2
viewer.selectedAnimation.value = 1
viewer.gizmoEnabled.value = true
viewer.gizmoMode.value = 'rotate'
await nextTick()
expect(mockLoad3d.setBackgroundColor).not.toHaveBeenCalled()
expect(mockLoad3d.toggleGrid).not.toHaveBeenCalled()
expect(mockLoad3d.toggleCamera).not.toHaveBeenCalled()
expect(mockLoad3d.setFOV).not.toHaveBeenCalled()
expect(mockLoad3d.setLightIntensity).not.toHaveBeenCalled()
expect(mockLoad3d.setBackgroundImage).not.toHaveBeenCalled()
expect(mockLoad3d.setBackgroundRenderMode).not.toHaveBeenCalled()
expect(mockLoad3d.setUpDirection).not.toHaveBeenCalled()
expect(mockLoad3d.setMaterialMode).not.toHaveBeenCalled()
expect(mockLoad3d.toggleAnimation).not.toHaveBeenCalled()
expect(mockLoad3d.setAnimationSpeed).not.toHaveBeenCalled()
expect(mockLoad3d.updateSelectedAnimation).not.toHaveBeenCalled()
expect(mockLoad3d.setGizmoEnabled).not.toHaveBeenCalled()
expect(mockLoad3d.setGizmoMode).not.toHaveBeenCalled()
})
it('should handle watcher errors gracefully', async () => {
vi.mocked(mockLoad3d.setBackgroundColor!).mockImplementationOnce(
function () {
@@ -425,82 +284,6 @@ describe('useLoad3dViewer', () => {
'toastMessages.failedToUpdateBackgroundColor'
)
})
it('surfaces watcher errors for each viewer control', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
vi.mocked(mockLoad3d.toggleGrid!).mockImplementation(() => {
throw new Error('grid failed')
})
vi.mocked(mockLoad3d.toggleCamera!).mockImplementation(() => {
throw new Error('camera failed')
})
vi.mocked(mockLoad3d.setFOV!).mockImplementation(() => {
throw new Error('fov failed')
})
vi.mocked(mockLoad3d.setLightIntensity!).mockImplementation(() => {
throw new Error('light failed')
})
vi.mocked(mockLoad3d.setBackgroundImage!).mockRejectedValue(
new Error('background failed')
)
vi.mocked(mockLoad3d.setBackgroundRenderMode!).mockImplementation(() => {
throw new Error('render failed')
})
vi.mocked(mockLoad3d.setUpDirection!).mockImplementation(() => {
throw new Error('up failed')
})
vi.mocked(mockLoad3d.setMaterialMode!).mockImplementation(() => {
throw new Error('material failed')
})
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
viewer.showGrid.value = false
await nextTick()
viewer.showGrid.value = true
viewer.cameraType.value = 'orthographic'
viewer.fov.value = 60
viewer.lightIntensity.value = 2
viewer.backgroundImage.value = 'bg.jpg'
viewer.backgroundRenderMode.value = 'panorama'
viewer.upDirection.value = '+y'
viewer.materialMode.value = 'normal'
await nextTick()
await Promise.resolve()
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToToggleGrid'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToToggleCamera'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToUpdateFOV'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToUpdateLightIntensity'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToUpdateBackgroundImage'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToUpdateBackgroundRenderMode'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToUpdateUpDirection'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToUpdateMaterialMode'
)
consoleErrorSpy.mockRestore()
})
})
describe('exportModel', () => {
@@ -585,42 +368,6 @@ describe('useLoad3dViewer', () => {
expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(true)
})
it('handles animation controls and seek after progress is known', async () => {
const eventHandlers: Record<string, (value: unknown) => void> = {}
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
eventHandlers[event] = handler as (value: unknown) => void
}
)
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
eventHandlers.animationListChange([{ name: 'Spin', index: 0 }])
eventHandlers.animationProgressChange({
progress: 25,
currentTime: 1,
duration: 8
})
viewer.playing.value = true
viewer.selectedSpeed.value = 0
viewer.selectedAnimation.value = undefined as unknown as number
await nextTick()
viewer.selectedSpeed.value = 2
viewer.selectedAnimation.value = 0
await nextTick()
viewer.handleSeek(50)
expect(viewer.animations.value).toEqual([{ name: 'Spin', index: 0 }])
expect(viewer.animationProgress.value).toBe(25)
expect(mockLoad3d.toggleAnimation).toHaveBeenCalledWith(true)
expect(mockLoad3d.setAnimationSpeed).toHaveBeenCalledWith(2)
expect(mockLoad3d.updateSelectedAnimation).toHaveBeenCalledWith(0)
expect(mockLoad3d.setAnimationTime).toHaveBeenCalledWith(4)
})
})
describe('restoreInitialState', () => {
@@ -675,14 +422,6 @@ describe('useLoad3dViewer', () => {
.futureField
).toBe('preserve-me')
})
it('does nothing in standalone mode', () => {
const viewer = useLoad3dViewer()
viewer.restoreInitialState()
expect(viewer.needApplyChanges.value).toBe(true)
})
})
describe('applyChanges', () => {
@@ -737,24 +476,6 @@ describe('useLoad3dViewer', () => {
expect(result).toBe(false)
})
it('applies without writing properties or dirtying when node state is absent', async () => {
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
mockNode.properties = undefined as unknown as LGraphNode['properties']
mockNode.graph = undefined as unknown as LGraphNode['graph']
const result = await viewer.applyChanges()
expect(result).toBe(true)
expect(mockLoad3dService.copyLoad3dState).toHaveBeenLastCalledWith(
mockLoad3d,
mockSourceLoad3d
)
})
it('should preserve unknown fields on Model Config when applying', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
@@ -857,37 +578,6 @@ describe('useLoad3dViewer', () => {
)
})
it('alerts when uploading without an active Load3d instance', async () => {
const viewer = useLoad3dViewer(mockNode)
await viewer.handleBackgroundImageUpdate(
new File([''], 'test.jpg', { type: 'image/jpeg' })
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.no3dScene'
)
expect(Load3dUtils.uploadFile).not.toHaveBeenCalled()
})
it('leaves existing background state when upload returns no path', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce('')
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
viewer.backgroundImage.value = 'existing.jpg'
viewer.hasBackgroundImage.value = true
await viewer.handleBackgroundImageUpdate(
new File([''], 'test.jpg', { type: 'image/jpeg' })
)
expect(viewer.backgroundImage.value).toBe('existing.jpg')
expect(viewer.hasBackgroundImage.value).toBe(true)
})
it('should work in standalone mode without a node', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce(
'uploaded-image.jpg'
@@ -974,69 +664,6 @@ describe('useLoad3dViewer', () => {
)
expect(mockLoad3d.loadModel).not.toHaveBeenCalled()
})
it('updates the model_file widget after a successful drop', async () => {
const modelWidget = {
name: 'model_file',
value: '',
options: { values: ['existing.glb'] }
}
mockNode.widgets = [modelWidget] as LGraphNode['widgets']
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce('3d/new.glb')
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
await viewer.handleModelDrop(new File([''], 'new.glb'))
expect(modelWidget.value).toBe('3d/new.glb')
expect(modelWidget.options.values).toEqual(['existing.glb', '3d/new.glb'])
})
it('does not duplicate model_file widget options', async () => {
const modelWidget = {
name: 'model_file',
value: '',
options: { values: ['3d/new.glb'] }
}
mockNode.widgets = [modelWidget] as LGraphNode['widgets']
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce('3d/new.glb')
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
await viewer.handleModelDrop(new File([''], 'new.glb'))
expect(modelWidget.options.values).toEqual(['3d/new.glb'])
})
it('alerts when model loading throws', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce('3d/bad.glb')
vi.mocked(mockLoad3d.loadModel!).mockRejectedValueOnce(
new Error('bad model')
)
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
await viewer.handleModelDrop(new File([''], 'bad.glb'))
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToLoadModel'
)
consoleErrorSpy.mockRestore()
})
})
describe('cleanup', () => {
@@ -1067,17 +694,6 @@ describe('useLoad3dViewer', () => {
expect(Load3d).not.toHaveBeenCalled()
})
it('should handle missing node in node-mode initialization', async () => {
const viewer = useLoad3dViewer()
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
expect(createLoad3d).not.toHaveBeenCalled()
})
it('should handle orthographic camera', async () => {
vi.mocked(mockSourceLoad3d.getCurrentCameraType!).mockReturnValue(
'orthographic'
@@ -1109,95 +725,6 @@ describe('useLoad3dViewer', () => {
})
describe('standalone mode persistence', () => {
it('should handle missing standalone container ref', async () => {
const viewer = useLoad3dViewer()
await viewer.initializeStandaloneViewer(null!, 'model.glb')
expect(createLoad3d).not.toHaveBeenCalled()
})
it('syncs pending hover state during standalone initialization', async () => {
const viewer = useLoad3dViewer()
viewer.handleMouseEnter()
await viewer.initializeStandaloneViewer(
document.createElement('div'),
'hover.glb'
)
expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(true)
})
it('shows an alert when first standalone load fails', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
vi.mocked(mockLoad3d.loadModel!).mockRejectedValueOnce(
new Error('load failed')
)
const viewer = useLoad3dViewer()
await viewer.initializeStandaloneViewer(
document.createElement('div'),
'broken.glb'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToLoadModel'
)
consoleErrorSpy.mockRestore()
})
it('shows an alert when reusing a standalone viewer fails to load the next model', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
const viewer = useLoad3dViewer()
await viewer.initializeStandaloneViewer(
document.createElement('div'),
'first.glb'
)
vi.mocked(mockLoad3d.loadModel!).mockRejectedValueOnce(
new Error('reload failed')
)
await viewer.initializeStandaloneViewer(
document.createElement('div'),
'second.glb'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'Failed to load 3D model'
)
consoleErrorSpy.mockRestore()
})
it('restores cached standalone camera state', async () => {
const cameraState = {
position: { x: 2, y: 3, z: 4 },
target: { x: 0, y: 0, z: 0 },
zoom: 2,
cameraType: 'perspective'
}
vi.mocked(mockLoad3d.getCameraState!).mockReturnValue(
fromAny(cameraState)
)
const viewer = useLoad3dViewer()
const containerRef = document.createElement('div')
await viewer.initializeStandaloneViewer(containerRef, 'camera.glb')
viewer.cleanup()
const restoredViewer = useLoad3dViewer()
await restoredViewer.initializeStandaloneViewer(
containerRef,
'camera.glb'
)
expect(mockLoad3d.setCameraState).toHaveBeenCalledWith(cameraState)
})
it('should save and restore configuration in standalone mode', async () => {
const viewer = useLoad3dViewer()
const containerRef = document.createElement('div')

View File

@@ -381,57 +381,4 @@ The MEDIA_SRC_REGEX handles both single and double quotes in img, video and sour
// Should have second node's content, not first
expect(helpContent.value).toBe('# Second node content')
})
it('returns empty state when no node is selected', async () => {
const nodeRef = ref<ComfyNodeDefImpl | null>(null)
const { baseUrl, helpContent, isLoading, error } =
useNodeHelpContent(nodeRef)
await nextTick()
expect(baseUrl.value).toBe('')
expect(helpContent.value).toBe('')
expect(isLoading.value).toBe(false)
expect(error.value).toBeNull()
expect(mockFetch).not.toHaveBeenCalled()
})
it('uses stringified non-error rejections with the node description', async () => {
const nodeRef = ref(mockCoreNode)
mockFetch.mockRejectedValueOnce('offline')
const { error, helpContent } = useNodeHelpContent(nodeRef)
await flushPromises()
expect(error.value).toBe('offline')
expect(helpContent.value).toBe(mockCoreNode.description)
})
it('ignores stale rejected requests after the node changes', async () => {
const nodeRef = ref(mockCoreNode)
let rejectFirst: (reason?: unknown) => void
const firstRequest = new Promise((_resolve, reject) => {
rejectFirst = reject
})
mockFetch
.mockImplementationOnce(() => firstRequest)
.mockResolvedValueOnce({
ok: true,
text: async () => '# Current node content'
})
const { error, helpContent } = useNodeHelpContent(nodeRef)
await nextTick()
nodeRef.value = mockCustomNode
await nextTick()
await flushPromises()
rejectFirst!(new Error('stale failure'))
await flushPromises()
expect(error.value).toBeNull()
expect(helpContent.value).toBe('# Current node content')
})
})

View File

@@ -23,7 +23,6 @@ import {
pasteVideoNodes,
usePaste
} from './usePaste'
import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
function createMockNode(): LGraphNode {
return createMockLGraphNode({
@@ -72,7 +71,7 @@ const mockCanvas = {
} as Partial<LGraphCanvas> as LGraphCanvas
const mockCanvasStore = {
canvas: mockCanvas as LGraphCanvas | null,
canvas: mockCanvas,
getCanvas: vi.fn(() => mockCanvas)
}
@@ -140,17 +139,6 @@ describe('pasteImageNode', () => {
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
it('returns null when image node creation fails', async () => {
vi.mocked(createNode).mockResolvedValue(null as never)
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
await expect(
pasteImageNode(mockCanvas, dataTransfer.items)
).resolves.toBeNull()
})
it('should use existing image node when provided', async () => {
const mockNode = createMockNode()
const file = createImageFile()
@@ -228,14 +216,6 @@ describe('pasteImageNodes', () => {
expect(createNode).not.toHaveBeenCalled()
expect(result).toEqual([])
})
it('omits files whose image node creation fails', async () => {
vi.mocked(createNode).mockResolvedValue(null as never)
const result = await pasteImageNodes(mockCanvas, [createImageFile()])
expect(result).toEqual([])
})
})
describe('pasteAudioNode', () => {
@@ -256,17 +236,6 @@ describe('pasteAudioNode', () => {
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
it('returns null when audio node creation fails', async () => {
vi.mocked(createNode).mockResolvedValue(null as never)
const file = createAudioFile()
const dataTransfer = createDataTransfer([file])
await expect(
pasteAudioNode(mockCanvas, dataTransfer.items)
).resolves.toBeNull()
})
it('should use existing audio node when provided', async () => {
const mockNode = createMockNode()
const file = createAudioFile()
@@ -343,14 +312,6 @@ describe('pasteAudioNodes', () => {
expect(createNode).toHaveBeenCalledTimes(1)
expect(result).toEqual([mockNode])
})
it('omits files whose audio node creation fails', async () => {
vi.mocked(createNode).mockResolvedValue(null as never)
const result = await pasteAudioNodes(mockCanvas, [createAudioFile()])
expect(result).toEqual([])
})
})
describe('pasteVideoNode', () => {
@@ -371,17 +332,6 @@ describe('pasteVideoNode', () => {
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
it('returns null when video node creation fails', async () => {
vi.mocked(createNode).mockResolvedValue(null as never)
const file = createVideoFile()
const dataTransfer = createDataTransfer([file])
await expect(
pasteVideoNode(mockCanvas, dataTransfer.items)
).resolves.toBeNull()
})
it('should use existing video node when provided', async () => {
const mockNode = createMockNode()
const file = createVideoFile()
@@ -458,23 +408,13 @@ describe('pasteVideoNodes', () => {
expect(createNode).toHaveBeenCalledTimes(1)
expect(result).toEqual([mockNode])
})
it('omits files whose video node creation fails', async () => {
vi.mocked(createNode).mockResolvedValue(null as never)
const result = await pasteVideoNodes(mockCanvas, [createVideoFile()])
expect(result).toEqual([])
})
})
describe('usePaste', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanvasStore.canvas = mockCanvas
mockCanvas.current_node = null
mockWorkspaceStore.shiftDown = false
vi.mocked(shouldIgnoreCopyPaste).mockReturnValue(false)
vi.mocked(mockCanvas.graph!.add).mockImplementation(
(node: LGraphNode | LGraphGroup | null) => node as LGraphNode
)
@@ -604,31 +544,6 @@ describe('usePaste', () => {
expect(createNode).not.toHaveBeenCalled()
})
it('ignores paste when the target owns native copy paste', () => {
vi.mocked(shouldIgnoreCopyPaste).mockReturnValue(true)
usePaste()
const dataTransfer = createDataTransfer([createImageFile()])
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
expect(createNode).not.toHaveBeenCalled()
})
it('ignores paste when the canvas is unavailable', () => {
mockCanvasStore.canvas = null
usePaste()
const dataTransfer = createDataTransfer([createImageFile()])
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
expect(createNode).not.toHaveBeenCalled()
expect(mockCanvas.pasteFromClipboard).not.toHaveBeenCalled()
})
it('should use existing image node when selected', () => {
const mockNode = createMockLGraphNode({
is_selected: true,
@@ -681,66 +596,6 @@ describe('usePaste', () => {
})
})
it('falls back to litegraph paste when metadata cannot be decoded', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
usePaste()
const dataTransfer = new DataTransfer()
dataTransfer.setData(
'text/html',
`<div data-metadata="${btoa('{')}"></div>`
)
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
await vi.waitFor(() => {
expect(mockCanvas._deserializeItems).not.toHaveBeenCalled()
expect(mockCanvas.pasteFromClipboard).toHaveBeenCalled()
})
errorSpy.mockRestore()
})
it('leaves plain text paste alone in text inputs', () => {
usePaste()
const input = document.createElement('input')
input.type = 'text'
document.body.append(input)
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/plain', 'plain text')
input.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true
})
)
expect(mockCanvas.pasteFromClipboard).not.toHaveBeenCalled()
input.remove()
})
it('leaves plain text paste alone in textareas', () => {
usePaste()
const textarea = document.createElement('textarea')
document.body.append(textarea)
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/plain', 'plain text')
textarea.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true
})
)
expect(mockCanvas.pasteFromClipboard).not.toHaveBeenCalled()
textarea.remove()
})
it('should skip node metadata paste when a media node is selected', async () => {
const mockNode = createMockLGraphNode({
is_selected: true,

View File

@@ -1,27 +0,0 @@
import { describe, expect, it } from 'vitest'
import { useProgressBarBackground } from './useProgressBarBackground'
describe('useProgressBarBackground', () => {
it('identifies finite progress values', () => {
const { hasProgressPercent, hasAnyProgressPercent } =
useProgressBarBackground()
expect(hasProgressPercent(undefined)).toBe(false)
expect(hasProgressPercent(Number.NaN)).toBe(false)
expect(hasProgressPercent(0)).toBe(true)
expect(hasAnyProgressPercent(undefined, Number.POSITIVE_INFINITY)).toBe(
false
)
expect(hasAnyProgressPercent(undefined, 42)).toBe(true)
})
it('clamps progress styles to the valid percent range', () => {
const { progressPercentStyle } = useProgressBarBackground()
expect(progressPercentStyle(undefined)).toBeUndefined()
expect(progressPercentStyle(-10)).toEqual({ width: '0%' })
expect(progressPercentStyle(125)).toEqual({ width: '100%' })
expect(progressPercentStyle(37)).toEqual({ width: '37%' })
})
})

View File

@@ -1,61 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive } from 'vue'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useRefreshableSelection } from '@/composables/useRefreshableSelection'
const mockCanvasStore = reactive({
selectedItems: [] as unknown[]
})
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasStore
}))
function makeNode(widgets?: unknown[]): LGraphNode {
const node = new LGraphNode('Test')
node.widgets = widgets as LGraphNode['widgets']
return node
}
describe('useRefreshableSelection', () => {
beforeEach(() => {
mockCanvasStore.selectedItems = []
})
it('does nothing when no selected widget is refreshable', async () => {
const selection = useRefreshableSelection()
await selection.refreshSelected()
expect(selection.isRefreshable.value).toBe(false)
})
it('refreshes selected widgets that expose a refresh function', async () => {
const refresh = vi.fn()
const ignoredRefresh = vi.fn()
mockCanvasStore.selectedItems = [
makeNode([{ refresh }, { refresh: 'not callable' }, null]),
{ widgets: [{ refresh: ignoredRefresh }] }
]
const selection = useRefreshableSelection()
await nextTick()
expect(selection.isRefreshable.value).toBe(true)
await selection.refreshSelected()
expect(refresh).toHaveBeenCalledOnce()
expect(ignoredRefresh).not.toHaveBeenCalled()
})
it('treats selected nodes without widgets as not refreshable', async () => {
mockCanvasStore.selectedItems = [makeNode()]
const selection = useRefreshableSelection()
await nextTick()
expect(selection.isRefreshable.value).toBe(false)
})
})

View File

@@ -20,13 +20,6 @@ vi.mock('@vueuse/core', () => ({
}))
describe('useServerLogs', () => {
const listenerFor = <T>(eventType: string) =>
vi
.mocked(useEventListener)
.mock.calls.find(([, type]) => type === eventType)?.[2] as
| ((event: CustomEvent<T>) => void)
| undefined
beforeEach(() => {
vi.clearAllMocks()
})
@@ -126,135 +119,4 @@ describe('useServerLogs', () => {
expect(logs.value).toEqual(['Log message 1 dont remove me', ''])
})
it('only captures logs while the matching task is active', async () => {
const { logs, startListening } = useServerLogs({ ui_id: 'task-1' })
await startListening()
expect(vi.mocked(useEventListener)).toHaveBeenCalledWith(
api,
'cm-task-started',
expect.any(Function)
)
expect(vi.mocked(useEventListener)).toHaveBeenCalledWith(
api,
'cm-task-completed',
expect.any(Function)
)
const onLogs = listenerFor<LogsWsMessage>('logs')
const onTaskStarted = listenerFor<{ ui_id: string }>('cm-task-started')
const onTaskDone = listenerFor<{ ui_id: string }>('cm-task-completed')
onLogs?.(
new CustomEvent('logs', {
detail: fromAny<LogsWsMessage, unknown>({
type: 'logs',
entries: [{ m: 'before start' }]
})
})
)
onTaskStarted?.(
new CustomEvent('cm-task-started', { detail: { ui_id: 'other-task' } })
)
onLogs?.(
new CustomEvent('logs', {
detail: fromAny<LogsWsMessage, unknown>({
type: 'logs',
entries: [{ m: 'wrong task' }]
})
})
)
onTaskStarted?.(
new CustomEvent('cm-task-started', { detail: { ui_id: 'task-1' } })
)
onLogs?.(
new CustomEvent('logs', {
detail: fromAny<LogsWsMessage, unknown>({
type: 'logs',
entries: [{ m: 'captured' }]
})
})
)
onTaskDone?.(
new CustomEvent('cm-task-completed', { detail: { ui_id: 'other-task' } })
)
onLogs?.(
new CustomEvent('logs', {
detail: fromAny<LogsWsMessage, unknown>({
type: 'logs',
entries: [{ m: 'still active' }]
})
})
)
onTaskDone?.(
new CustomEvent('cm-task-completed', { detail: { ui_id: 'task-1' } })
)
onLogs?.(
new CustomEvent('logs', {
detail: fromAny<LogsWsMessage, unknown>({
type: 'logs',
entries: [{ m: 'after done' }]
})
})
)
expect(logs.value).toEqual(['captured', 'still active'])
})
it('ignores invalid and empty log events', async () => {
const { logs, startListening } = useServerLogs()
await startListening()
const onLogs = listenerFor<LogsWsMessage>('logs')
onLogs?.(
new CustomEvent('not-logs', {
detail: fromAny<LogsWsMessage, unknown>({
type: 'logs',
entries: [{ m: 'wrong event' }]
})
})
)
onLogs?.(
new CustomEvent('logs', {
detail: fromAny<LogsWsMessage, unknown>({
type: 'logs',
entries: []
})
})
)
onLogs?.(
new CustomEvent('logs', {
detail: fromAny<LogsWsMessage, unknown>({
type: 'logs',
entries: [{ m: ' ' }]
})
})
)
expect(logs.value).toEqual([])
})
it('stops every registered listener', async () => {
const stopLogs = vi.fn()
const stopTaskStarted = vi.fn()
const stopTaskDone = vi.fn()
vi.mocked(useEventListener)
.mockReturnValueOnce(stopLogs)
.mockReturnValueOnce(stopTaskStarted)
.mockReturnValueOnce(stopTaskDone)
const { startListening, stopListening } = useServerLogs({ ui_id: 'task-1' })
await startListening()
await stopListening()
expect(stopLogs).toHaveBeenCalledTimes(1)
expect(stopTaskStarted).toHaveBeenCalledTimes(1)
expect(stopTaskDone).toHaveBeenCalledTimes(1)
expect(api.subscribeLogs).toHaveBeenLastCalledWith(false)
})
})

View File

@@ -248,157 +248,6 @@ describe('useTemplateFiltering', () => {
])
})
it('accepts plain template arrays and treats invalid ref values as empty', () => {
const plain = useTemplateFiltering([
{
name: 'plain-template',
description: 'Plain template',
mediaType: 'image',
mediaSubtype: 'png'
}
])
expect(
plain.filteredTemplates.value.map((template) => template.name)
).toEqual(['plain-template'])
const invalid = useTemplateFiltering(
ref('not-an-array') as unknown as Parameters<
typeof useTemplateFiltering
>[0]
)
expect(invalid.filteredTemplates.value).toEqual([])
expect(invalid.totalCount.value).toBe(0)
})
it('ignores malformed models and tags while applying active filters', () => {
const templates = ref<TemplateInfo[]>([
{
name: 'model-template',
description: 'Model template',
mediaType: 'image',
mediaSubtype: 'png',
models: ['Flux'],
tags: ['Video']
},
{
name: 'missing-models',
description: 'Missing models',
mediaType: 'image',
mediaSubtype: 'png',
models: 'Flux' as unknown as string[],
tags: 'Video' as unknown as string[]
}
])
const {
availableModels,
availableUseCases,
selectedModels,
selectedUseCases,
filteredTemplates
} = useTemplateFiltering(templates)
expect(availableModels.value).toEqual(['Flux'])
expect(availableUseCases.value).toEqual(['Video'])
selectedModels.value = ['Flux']
selectedUseCases.value = ['Video']
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'model-template'
])
})
it('returns no templates for unknown runs-on filters', () => {
const templates = ref<TemplateInfo[]>([
{
name: 'template',
description: 'Template',
mediaType: 'image',
mediaSubtype: 'png'
}
])
const { selectedRunsOn, filteredTemplates } =
useTemplateFiltering(templates)
selectedRunsOn.value = ['Unknown target']
expect(filteredTemplates.value).toEqual([])
})
it('supports recommended and popular score sorting', async () => {
defaultRankingStore.computeDefaultScore.mockImplementation(
(_date?: string, rank?: number) => rank ?? 0
)
defaultRankingStore.computePopularScore.mockImplementation(
(_date?: string, usage?: number) => usage ?? 0
)
const templates = ref<TemplateInfo[]>([
{
name: 'low',
description: 'Low score',
mediaType: 'image',
mediaSubtype: 'png',
searchRank: 1,
usage: 10
},
{
name: 'high',
description: 'High score',
mediaType: 'image',
mediaSubtype: 'png',
searchRank: 9,
usage: 1
}
])
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
sortBy.value = 'recommended'
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'high',
'low'
])
sortBy.value = 'popular'
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'low',
'high'
])
})
it('keeps equal and missing model sizes stable for size sorting', async () => {
const templates = ref<TemplateInfo[]>([
{
name: 'first',
description: 'First',
mediaType: 'image',
mediaSubtype: 'png'
},
{
name: 'second',
description: 'Second',
mediaType: 'image',
mediaSubtype: 'png'
}
])
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
sortBy.value = 'model-size-low-to-high'
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'first',
'second'
])
})
describe('loadFuseOptions', () => {
it('updates fuseOptions when getFuseOptions returns valid options', async () => {
const templates = ref<TemplateInfo[]>([

View File

@@ -1,102 +0,0 @@
import { ref } from 'vue'
import { describe, expect, it } from 'vitest'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import type { TreeNode } from '@/types/treeExplorerTypes'
function node(over: Partial<TreeNode>): TreeNode {
return over as TreeNode
}
// root ─┬─ a ── a1 (leaf)
// └─ b (leaf)
function sampleTree() {
const a1 = node({ key: 'a1', leaf: true })
const a = node({ key: 'a', leaf: false, children: [a1] })
const b = node({ key: 'b', leaf: true })
const root = node({ key: 'root', leaf: false, children: [a, b] })
return { root, a, a1, b }
}
describe('useTreeExpansion', () => {
it('toggleNode adds then removes a node key', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { toggleNode } = useTreeExpansion(expandedKeys)
const n = node({ key: 'x' })
toggleNode(n)
expect(expandedKeys.value).toEqual({ x: true })
toggleNode(n)
expect(expandedKeys.value).toEqual({})
})
it('toggleNode ignores nodes without a string key', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { toggleNode } = useTreeExpansion(expandedKeys)
toggleNode(node({ key: undefined }))
toggleNode(node({ key: 42 as unknown as string }))
expect(expandedKeys.value).toEqual({})
})
it('expandNode expands the node and all non-leaf descendants only', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode } = useTreeExpansion(expandedKeys)
const { root } = sampleTree()
expandNode(root)
// root and a are folders; a1 and b are leaves and must be skipped
expect(expandedKeys.value).toEqual({ root: true, a: true })
})
it('expandNode does nothing for a leaf node', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode } = useTreeExpansion(expandedKeys)
expandNode(node({ key: 'leaf', leaf: true }))
expect(expandedKeys.value).toEqual({})
})
it('collapseNode removes the node and its non-leaf descendants', () => {
const expandedKeys = ref<Record<string, boolean>>({
root: true,
a: true,
stray: true
})
const { collapseNode } = useTreeExpansion(expandedKeys)
const { root } = sampleTree()
collapseNode(root)
expect(expandedKeys.value).toEqual({ stray: true })
})
it('toggleNodeRecursive expands when collapsed and collapses when expanded', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { toggleNodeRecursive } = useTreeExpansion(expandedKeys)
const { root } = sampleTree()
toggleNodeRecursive(root)
expect(expandedKeys.value).toEqual({ root: true, a: true })
toggleNodeRecursive(root)
expect(expandedKeys.value).toEqual({})
})
it('toggleNodeOnEvent toggles recursively with ctrl and singly without', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
const { root } = sampleTree()
toggleNodeOnEvent(new KeyboardEvent('keydown', { ctrlKey: true }), root)
expect(expandedKeys.value).toEqual({ root: true, a: true })
// Plain toggle removes only the node's own key, leaving descendants
toggleNodeOnEvent(new MouseEvent('click'), root)
expect(expandedKeys.value).toEqual({ a: true })
})
})

View File

@@ -1,105 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useViewErrorsInGraph } from './useViewErrorsInGraph'
const apiMock = vi.hoisted(() => ({
getSettings: vi.fn(),
storeSetting: vi.fn(),
storeSettings: vi.fn()
}))
vi.mock('@/scripts/api', () => ({
api: apiMock
}))
const appMock = vi.hoisted(() => ({
ui: {
settings: {
dispatchChange: vi.fn()
}
},
rootGraph: {
events: new EventTarget(),
nodes: []
}
}))
vi.mock('@/scripts/app', () => ({
app: appMock
}))
function createSelectedCanvas() {
const graph = new LGraph()
const canvasElement = document.createElement('canvas')
canvasElement.width = 800
canvasElement.height = 600
canvasElement.getContext = vi
.fn()
.mockReturnValue(createMockCanvasRenderingContext2D())
const canvas = new LGraphCanvas(canvasElement, graph, {
skip_events: true,
skip_render: true
})
const node = new LGraphNode('Selected Node')
graph.add(node)
canvas.selectedItems.add(node)
node.selected = true
return { canvas, node }
}
describe('useViewErrorsInGraph', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
apiMock.getSettings.mockResolvedValue({})
apiMock.storeSetting.mockResolvedValue(undefined)
apiMock.storeSettings.mockResolvedValue(undefined)
})
it('opens graph errors and clears app-mode error UI state', () => {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const workflowStore = useWorkflowStore()
const { canvas, node } = createSelectedCanvas()
workflowStore.activeWorkflow = {
activeMode: 'app'
} as typeof workflowStore.activeWorkflow
canvasStore.canvas = canvas
canvasStore.selectedItems = [node]
executionErrorStore.showErrorOverlay()
useViewErrorsInGraph().viewErrorsInGraph()
expect(node.selected).toBe(false)
expect(canvasStore.linearMode).toBe(false)
expect(canvasStore.selectedItems).toEqual([])
expect(rightSidePanelStore.activeTab).toBe('errors')
expect(rightSidePanelStore.isOpen).toBe(true)
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
})
it('opens graph errors when the canvas is not initialized', () => {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
canvasStore.canvas = null
executionErrorStore.showErrorOverlay()
expect(() => useViewErrorsInGraph().viewErrorsInGraph()).not.toThrow()
expect(rightSidePanelStore.activeTab).toBe('errors')
expect(rightSidePanelStore.isOpen).toBe(true)
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
})
})

View File

@@ -1,22 +0,0 @@
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
export function useViewErrorsInGraph() {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
function viewErrorsInGraph() {
canvasStore.linearMode = false
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()
}
rightSidePanelStore.openPanel('errors')
executionErrorStore.dismissErrorOverlay()
}
return { viewErrorsInGraph }
}

View File

@@ -1,57 +1,24 @@
import { fromAny } from '@total-typescript/shoehorn'
import { ref } from 'vue'
import type { Ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useWaveAudioPlayer } from './useWaveAudioPlayer'
type MediaControls = {
playing: Ref<boolean>
currentTime: Ref<number>
duration: Ref<number>
volume: Ref<number>
muted: Ref<boolean>
}
const mockMediaControls = vi.hoisted(() => ({
values: [] as MediaControls[]
}))
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>()
return {
...actual,
useMediaControls: () =>
mockMediaControls.values.shift() ?? {
playing: ref(false),
currentTime: ref(0),
duration: ref(0),
volume: ref(1),
muted: ref(false)
}
useMediaControls: () => ({
playing: ref(false),
currentTime: ref(0),
duration: ref(0)
})
}
})
const mockFetchApi = vi.fn()
const originalAudioContext = globalThis.AudioContext
function queueMediaControls(overrides: Partial<MediaControls> = {}) {
const controls: MediaControls = {
playing: ref(false),
currentTime: ref(0),
duration: ref(0),
volume: ref(1),
muted: ref(false),
...overrides
}
mockMediaControls.values.push(controls)
return controls
}
beforeEach(() => {
mockMediaControls.values = []
})
afterEach(() => {
globalThis.AudioContext = originalAudioContext
mockFetchApi.mockReset()
@@ -83,21 +50,6 @@ describe('useWaveAudioPlayer', () => {
expect(playedBarIndex.value).toBe(-1)
})
it('computes progress and played bar when duration is known', () => {
queueMediaControls({
currentTime: ref(30),
duration: ref(120)
})
const src = ref('')
const { playedBarIndex, progressRatio } = useWaveAudioPlayer({
src,
barCount: 40
})
expect(playedBarIndex.value).toBe(9)
expect(progressRatio.value).toBe(25)
})
it('generates bars with heights between 10 and 70', () => {
const src = ref('')
const { bars } = useWaveAudioPlayer({ src })
@@ -113,56 +65,6 @@ describe('useWaveAudioPlayer', () => {
expect(isPlaying.value).toBe(false)
})
it('updates playback and seek controls', () => {
const controls = queueMediaControls({
currentTime: ref(10),
duration: ref(100)
})
const src = ref('')
const player = useWaveAudioPlayer({ src })
player.togglePlayPause()
expect(player.isPlaying.value).toBe(true)
player.seekToStart()
expect(controls.currentTime.value).toBe(0)
player.seekToRatio(0.25)
expect(controls.currentTime.value).toBe(25)
player.seekToRatio(-1)
expect(controls.currentTime.value).toBe(0)
player.seekToRatio(2)
expect(controls.currentTime.value).toBe(100)
player.seekToEnd()
expect(controls.currentTime.value).toBe(100)
expect(player.isPlaying.value).toBe(false)
})
it('updates mute state and volume icon', () => {
const controls = queueMediaControls({
volume: ref(1),
muted: ref(false)
})
const src = ref('')
const player = useWaveAudioPlayer({ src })
expect(player.volumeIcon.value).toBe('icon-[lucide--volume-2]')
controls.volume.value = 0.25
expect(player.volumeIcon.value).toBe('icon-[lucide--volume-1]')
controls.volume.value = 0
expect(player.volumeIcon.value).toBe('icon-[lucide--volume-x]')
controls.volume.value = 1
player.toggleMute()
expect(controls.muted.value).toBe(true)
expect(player.volumeIcon.value).toBe('icon-[lucide--volume-x]')
})
it('shows 0:00 for formatted times initially', () => {
const src = ref('')
const { formattedCurrentTime, formattedDuration } = useWaveAudioPlayer({
@@ -206,91 +108,6 @@ describe('useWaveAudioPlayer', () => {
expect(bars.value).toHaveLength(10)
})
it('uses placeholder bars when decoded audio has no channel data', async () => {
const mockAudioBuffer = {
getChannelData: vi.fn(() => new Float32Array())
}
const mockDecodeAudioData = vi.fn(() => Promise.resolve(mockAudioBuffer))
const mockClose = vi.fn().mockResolvedValue(undefined)
globalThis.AudioContext = fromAny<typeof AudioContext, unknown>(
class {
decodeAudioData = mockDecodeAudioData
close = mockClose
}
)
mockFetchApi.mockResolvedValue({
ok: true,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8))
})
const src = ref('/api/view?filename=empty.wav&type=output')
const { bars, loading } = useWaveAudioPlayer({ src, barCount: 6 })
await vi.waitFor(() => {
expect(loading.value).toBe(false)
})
expect(bars.value).toHaveLength(6)
for (const bar of bars.value) {
expect(bar.height).toBeGreaterThanOrEqual(10)
expect(bar.height).toBeLessThanOrEqual(70)
}
})
it('uses placeholder bars when fetching audio fails', async () => {
mockFetchApi.mockResolvedValue({
ok: false,
status: 500
})
const src = ref('https://example.com/audio.wav')
const { bars, loading } = useWaveAudioPlayer({ src, barCount: 5 })
await vi.waitFor(() => {
expect(loading.value).toBe(false)
})
expect(mockFetchApi).toHaveBeenCalledWith('https://example.com/audio.wav')
expect(bars.value).toHaveLength(5)
})
it('seeks from waveform clicks and starts playback', () => {
const controls = queueMediaControls({
duration: ref(100)
})
const src = ref('')
const player = useWaveAudioPlayer({ src })
player.handleWaveformClick(fromAny<MouseEvent, unknown>({ clientX: 50 }))
expect(controls.currentTime.value).toBe(0)
player.waveformRef.value = fromAny<HTMLElement, unknown>({
getBoundingClientRect: () => ({ left: 10, width: 100 })
})
player.handleWaveformClick(fromAny<MouseEvent, unknown>({ clientX: 60 }))
expect(controls.currentTime.value).toBe(50)
expect(player.isPlaying.value).toBe(true)
player.handleWaveformClick(fromAny<MouseEvent, unknown>({ clientX: -100 }))
expect(controls.currentTime.value).toBe(0)
player.handleWaveformClick(fromAny<MouseEvent, unknown>({ clientX: 999 }))
expect(controls.currentTime.value).toBe(100)
})
it('ignores waveform clicks when duration is zero', () => {
const controls = queueMediaControls()
const src = ref('')
const player = useWaveAudioPlayer({ src })
player.waveformRef.value = fromAny<HTMLElement, unknown>({
getBoundingClientRect: () => ({ left: 0, width: 100 })
})
player.handleWaveformClick(fromAny<MouseEvent, unknown>({ clientX: 50 }))
expect(controls.currentTime.value).toBe(0)
expect(player.isPlaying.value).toBe(false)
})
it('does not call decodeAudioSource when src is empty', () => {
const src = ref('')
useWaveAudioPlayer({ src })

View File

@@ -25,6 +25,6 @@ function handleClose() {
}
function handleSubscribe() {
showSubscriptionDialog({ reason: 'upload_model_upgrade' })
showSubscriptionDialog()
}
</script>

View File

@@ -140,10 +140,7 @@ describe('CloudSubscriptionRedirectView', () => {
expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith(
'creator',
'monthly',
{
openInNewTab: false,
paymentIntentSource: 'deep_link'
}
false
)
// Shows loading affordances
@@ -172,10 +169,7 @@ describe('CloudSubscriptionRedirectView', () => {
expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith(
'creator',
'monthly',
{
openInNewTab: false,
paymentIntentSource: 'deep_link'
}
false
)
})
@@ -186,8 +180,7 @@ describe('CloudSubscriptionRedirectView', () => {
expect(screen.getByText('Subscribe to Team Plan')).toBeInTheDocument()
expect(mockPerformTeamSubscriptionCheckout).toHaveBeenCalledWith(
'team_700',
'yearly',
{ paymentIntentSource: 'deep_link' }
'yearly'
)
// Team never goes through the personal checkout path
expect(mockPerformSubscriptionCheckout).not.toHaveBeenCalled()

View File

@@ -94,9 +94,7 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
return
}
isTeamCheckout.value = true
await performTeamSubscriptionCheckout(stopId, billingCycle, {
paymentIntentSource: 'deep_link'
})
await performTeamSubscriptionCheckout(stopId, billingCycle)
return
}
@@ -114,10 +112,7 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
if (isActiveSubscription.value) {
await accessBillingPortal(undefined, false)
} else {
await performSubscriptionCheckout(tierKeyParam, billingCycle, {
openInNewTab: false,
paymentIntentSource: 'deep_link'
})
await performSubscriptionCheckout(tierKeyParam, billingCycle, false)
}
}, reportError)

View File

@@ -351,12 +351,12 @@ const handleRefresh = wrapWithErrorHandlingAsync(async () => {
})
function handleAddCredits() {
telemetry?.trackAddApiCreditButtonClicked({ source: 'credits_panel' })
telemetry?.trackAddApiCreditButtonClicked()
void dialogService.showTopUpCreditsDialog()
}
function handleUpgradeToAddCredits() {
showPricingTable({ reason: 'upgrade_to_add_credits' })
showPricingTable()
}
async function handleWindowFocus() {

View File

@@ -5,8 +5,6 @@ import { render, screen } from '@testing-library/vue'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import FreeTierDialogContent from './FreeTierDialogContent.vue'
const mockRenewalDate = vi.hoisted(() => ({ value: null as string | null }))
@@ -17,7 +15,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
}))
}))
function renderComponent(props?: { reason?: PaymentIntentSource }) {
function renderComponent() {
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -25,7 +23,6 @@ function renderComponent(props?: { reason?: PaymentIntentSource }) {
})
return render(FreeTierDialogContent, {
props,
global: {
plugins: [i18n]
}
@@ -46,18 +43,4 @@ describe('FreeTierDialogContent', () => {
renderComponent()
expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument()
})
it('keeps the generic copy for intent reasons outside the credits variants', () => {
mockRenewalDate.value = '2026-07-15T10:00:00Z'
renderComponent({ reason: 'subscribe_to_run' })
expect(
screen.getByText('Your credits refresh on Jul 15, 2026.')
).toBeInTheDocument()
})
it('swaps to the out-of-credits copy without the refresh line', () => {
mockRenewalDate.value = '2026-07-15T10:00:00Z'
renderComponent({ reason: 'out_of_credits' })
expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument()
})
})

View File

@@ -52,7 +52,7 @@
</p>
<p
v-if="!isCreditsBlockedVariant"
v-if="!reason || reason === 'subscription_required'"
class="m-0 text-sm text-text-secondary"
>
{{
@@ -65,7 +65,10 @@
</p>
<p
v-if="!isCreditsBlockedVariant && formattedRenewalDate"
v-if="
(!reason || reason === 'subscription_required') &&
formattedRenewalDate
"
class="m-0 text-sm text-text-secondary"
>
{{
@@ -85,7 +88,7 @@
@click="$emit('upgrade')"
>
{{
isCreditsBlockedVariant
reason === 'out_of_credits' || reason === 'top_up_blocked'
? $t('subscription.freeTier.upgradeCta')
: $t('subscription.freeTier.subscribeCta')
}}
@@ -100,12 +103,12 @@ import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
const { reason } = defineProps<{
reason?: PaymentIntentSource
defineProps<{
reason?: SubscriptionDialogReason
}>()
defineEmits<{
@@ -126,10 +129,4 @@ const formattedRenewalDate = computed(() => {
})
const freeTierCredits = computed(() => getTierCredits('free'))
// Only these two variants replace the generic free-tier copy; any other
// intent reason (subscribe_to_run, deep_link, ...) keeps the default pitch.
const isCreditsBlockedVariant = computed(
() => reason === 'out_of_credits' || reason === 'top_up_blocked'
)
</script>

View File

@@ -261,7 +261,6 @@ describe('PricingTable', () => {
tier: 'creator',
cycle: 'yearly',
checkout_type: 'change',
checkout_attempt_id: expect.any(String),
previous_tier: 'standard'
})
expect(mockAccessBillingPortal).toHaveBeenCalledWith('creator-yearly')
@@ -342,7 +341,6 @@ describe('PricingTable', () => {
expect(
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
).toBeNull()
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
})
it('should use the latest userId value when it changes after mount', async () => {
@@ -368,7 +366,6 @@ describe('PricingTable', () => {
tier: 'creator',
cycle: 'yearly',
checkout_type: 'change',
checkout_attempt_id: expect.any(String),
previous_tier: 'standard'
})
})

View File

@@ -277,19 +277,13 @@ import type {
TierKey,
TierPricing
} from '@/platform/cloud/subscription/constants/tierPricing'
import {
recordPendingSubscriptionCheckoutAttempt,
withPendingCheckoutAttemptId
} from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil'
import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type {
CheckoutAttributionMetadata,
PaymentIntentSource
} from '@/platform/telemetry/types'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import { useAuthStore } from '@/stores/authStore'
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
@@ -327,10 +321,6 @@ interface PricingTierConfig {
isPopular?: boolean
}
const { reason } = defineProps<{
reason?: PaymentIntentSource
}>()
const emit = defineEmits<{
chooseTeamWorkspace: []
}>()
@@ -473,17 +463,16 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
} as const
const previousPlan = currentPlanDescriptor.value
const checkoutAttribution = await getCheckoutAttributionForCloud()
const beginCheckoutMetadata = userId.value
? {
user_id: userId.value,
tier: targetPlan.tierKey,
cycle: targetPlan.billingCycle,
checkout_type: 'change' as const,
...(reason ? { payment_intent_source: reason } : {}),
...checkoutAttribution,
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {})
}
: null
if (userId.value) {
telemetry?.trackBeginCheckout({
user_id: userId.value,
tier: targetPlan.tierKey,
cycle: targetPlan.billingCycle,
checkout_type: 'change',
...checkoutAttribution,
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {})
})
}
// Pass the target tier to create a deep link to subscription update confirmation
const checkoutTier = getCheckoutTier(
targetPlan.tierKey,
@@ -498,39 +487,29 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
if (downgrade) {
// TODO(COMFY-StripeProration): Remove once backend checkout creation mirrors portal proration ("change at billing end")
const didOpenPortal = await accessBillingPortal()
if (didOpenPortal && beginCheckoutMetadata) {
telemetry?.trackBeginCheckout(beginCheckoutMetadata)
}
await accessBillingPortal()
} else {
const didOpenPortal = await accessBillingPortal(checkoutTier)
if (!didOpenPortal) {
return
}
const pendingAttempt = recordPendingSubscriptionCheckoutAttempt({
recordPendingSubscriptionCheckoutAttempt({
tier: targetPlan.tierKey,
cycle: targetPlan.billingCycle,
checkout_type: 'change',
payment_intent_source: reason,
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {}),
...(previousPlan
? { previous_cycle: previousPlan.billingCycle }
: {})
})
if (beginCheckoutMetadata) {
telemetry?.trackBeginCheckout(
withPendingCheckoutAttemptId(
beginCheckoutMetadata,
pendingAttempt
)
)
}
}
} else {
await performSubscriptionCheckout(tierKey, currentBillingCycle.value, {
paymentIntentSource: reason
})
await performSubscriptionCheckout(
tierKey,
currentBillingCycle.value,
true
)
}
} finally {
isLoading.value = false

View File

@@ -56,7 +56,7 @@ const handleSubscribe = () => {
current_tier: tier.value?.toLowerCase()
})
isAwaitingStripeSubscription.value = true
showSubscriptionDialog({ reason: 'subscribe_now_button' })
showSubscriptionDialog()
}
onBeforeUnmount(() => {

View File

@@ -54,6 +54,6 @@ function handleSubscribeToRun() {
trackRunButton({ subscribe_to_run: true })
}
showSubscriptionDialog({ reason: 'subscribe_to_run' })
showSubscriptionDialog()
}
</script>

View File

@@ -48,9 +48,7 @@
v-if="isActiveSubscription"
variant="primary"
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary"
@click="
showSubscriptionDialog({ reason: 'settings_billing_panel' })
"
@click="showSubscriptionDialog"
>
{{ $t('subscription.upgradePlan') }}
</Button>

View File

@@ -33,11 +33,7 @@
</i18n-t>
</div>
<PricingTable
:reason
class="flex-1"
@choose-team-workspace="handleChooseTeam"
/>
<PricingTable class="flex-1" @choose-team-workspace="handleChooseTeam" />
<!-- Contact and Enterprise Links -->
<div class="flex flex-col items-center gap-2">
@@ -161,11 +157,11 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
const { onClose, reason, onChooseTeam } = defineProps<{
onClose: () => void
reason?: PaymentIntentSource
reason?: SubscriptionDialogReason
onChooseTeam?: () => void
}>()

View File

@@ -24,9 +24,7 @@ export function useAccountPreconditionDialog() {
)
return
case 'subscription':
void dialogService.showSubscriptionRequiredDialog({
reason: 'subscription_required'
})
void dialogService.showSubscriptionRequiredDialog()
return
case 'credits':
void dialogService.showTopUpCreditsDialog({

View File

@@ -55,6 +55,12 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
})
}))
const mockTrackSubscription = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackSubscription: mockTrackSubscription })
}))
describe('usePricingTableUrlLoader', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -90,6 +96,9 @@ describe('usePricingTableUrlLoader', () => {
reason: 'deep_link',
planMode: undefined
})
expect(mockTrackSubscription).toHaveBeenCalledWith('modal_opened', {
reason: 'deep_link'
})
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
})
@@ -141,6 +150,7 @@ describe('usePricingTableUrlLoader', () => {
await loadPricingTableFromUrl()
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockTrackSubscription).not.toHaveBeenCalled()
})
it('denies, strips, and clears together when the user is not eligible', async () => {
@@ -151,6 +161,7 @@ describe('usePricingTableUrlLoader', () => {
await loadPricingTableFromUrl()
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockTrackSubscription).not.toHaveBeenCalled()
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { other: 'param' }
})
@@ -219,6 +230,7 @@ describe('usePricingTableUrlLoader', () => {
)
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockTrackSubscription).not.toHaveBeenCalled()
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'pricing'

View File

@@ -7,6 +7,7 @@ import {
mergePreservedQueryIntoQuery
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
@@ -61,6 +62,7 @@ export function usePricingTableUrlLoader() {
const planMode =
param === 'team' || param === 'personal' ? param : undefined
useTelemetry()?.trackSubscription('modal_opened', { reason: 'deep_link' })
subscriptionDialog.showPricingTable({ reason: 'deep_link', planMode })
}

View File

@@ -15,7 +15,7 @@ import { t } from '@/i18n'
import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
import { useDialogService } from '@/services/dialogService'
@@ -237,7 +237,14 @@ function useSubscriptionInternal() {
})
}, reportError)
const showSubscriptionDialog = (options?: SubscriptionDialogOptions) => {
const showSubscriptionDialog = (options?: {
reason?: SubscriptionDialogReason
}) => {
useTelemetry()?.trackSubscription('modal_opened', {
current_tier: subscriptionTier.value?.toLowerCase(),
reason: options?.reason
})
void showSubscriptionRequiredDialog(options)
}
@@ -270,7 +277,7 @@ function useSubscriptionInternal() {
await fetchSubscriptionStatus()
if (!isSubscribedOrIsNotCloud.value) {
showSubscriptionDialog({ reason: 'subscription_required' })
showSubscriptionDialog()
}
}

View File

@@ -39,23 +39,15 @@ vi.mock('@/stores/commandStore', () => ({
}))
// useTelemetry() returns null in OSS, a dispatcher in cloud — toggle via mockIsCloud.
const {
mockIsCloud,
mockTrackHelpResourceClicked,
mockTrackAddApiCreditButtonClicked
} = vi.hoisted(() => ({
const { mockIsCloud, mockTrackHelpResourceClicked } = vi.hoisted(() => ({
mockIsCloud: { value: true },
mockTrackHelpResourceClicked: vi.fn(),
mockTrackAddApiCreditButtonClicked: vi.fn()
mockTrackHelpResourceClicked: vi.fn()
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () =>
mockIsCloud.value
? {
trackHelpResourceClicked: mockTrackHelpResourceClicked,
trackAddApiCreditButtonClicked: mockTrackAddApiCreditButtonClicked
}
? { trackHelpResourceClicked: mockTrackHelpResourceClicked }
: null
}))
@@ -77,9 +69,6 @@ describe('useSubscriptionActions', () => {
const { handleAddApiCredits } = useSubscriptionActions()
handleAddApiCredits()
expect(mockShowTopUpCreditsDialog).toHaveBeenCalledOnce()
expect(mockTrackAddApiCreditButtonClicked).toHaveBeenCalledWith({
source: 'settings_billing_panel'
})
})
})

View File

@@ -21,9 +21,6 @@ export function useSubscriptionActions() {
})
const handleAddApiCredits = () => {
telemetry?.trackAddApiCreditButtonClicked({
source: 'settings_billing_panel'
})
void dialogService.showTopUpCreditsDialog()
}

View File

@@ -5,10 +5,8 @@ import { useSubscriptionDialog } from './useSubscriptionDialog'
const mockCloseDialog = vi.fn()
const mockShowLayoutDialog = vi.fn()
const mockShowTeamWorkspacesDialog = vi.fn()
const mockTrackSubscription = vi.hoisted(() => vi.fn())
const mockIsInPersonalWorkspace = vi.hoisted(() => ({ value: true }))
const mockIsFreeTier = vi.hoisted(() => ({ value: false }))
const mockTier = vi.hoisted(() => ({ value: 'FREE' as string | null }))
const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: false }))
const mockIsCloud = vi.hoisted(() => ({ value: true }))
const mockIsLegacyTeamPlan = vi.hoisted(() => ({ value: false }))
@@ -62,15 +60,10 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isFreeTier: mockIsFreeTier,
isLegacyTeamPlan: mockIsLegacyTeamPlan,
tier: mockTier
isLegacyTeamPlan: mockIsLegacyTeamPlan
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackSubscription: mockTrackSubscription })
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: {
@@ -87,7 +80,6 @@ describe('useSubscriptionDialog', () => {
mockIsCloud.value = true
mockIsInPersonalWorkspace.value = true
mockIsFreeTier.value = false
mockTier.value = 'FREE'
mockTeamWorkspacesEnabled.value = false
mockIsLegacyTeamPlan.value = false
mockCanManageSubscription.value = true
@@ -206,51 +198,6 @@ describe('useSubscriptionDialog', () => {
const props = mockShowLayoutDialog.mock.calls[0][0].props
expect(props.initialPlanMode).toBe('team')
})
it('tracks modal_opened with the caller reason and current tier', () => {
mockTier.value = 'STANDARD'
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'upgrade_to_add_credits' })
expect(mockTrackSubscription).toHaveBeenCalledWith('modal_opened', {
current_tier: 'standard',
reason: 'upgrade_to_add_credits'
})
})
it('tracks modal_opened on the workspace (unified) path too', () => {
mockTeamWorkspacesEnabled.value = true
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'subscribe_to_run' })
expect(mockTrackSubscription).toHaveBeenCalledWith(
'modal_opened',
expect.objectContaining({ reason: 'subscribe_to_run' })
)
})
it('does not track modal_opened for the inactive member dialog', () => {
mockTeamWorkspacesEnabled.value = true
mockIsInPersonalWorkspace.value = false
mockCanManageSubscription.value = false
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'subscribe_to_run' })
expect(mockShowLayoutDialog).toHaveBeenCalledTimes(1)
expect(mockTrackSubscription).not.toHaveBeenCalled()
})
it('does not track on non-cloud', () => {
mockIsCloud.value = false
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'subscribe_to_run' })
expect(mockTrackSubscription).not.toHaveBeenCalled()
})
})
describe('show', () => {
@@ -288,20 +235,6 @@ describe('useSubscriptionDialog', () => {
expect.objectContaining({ key: 'subscription-required' })
)
})
it('tracks modal_opened with the reason for the free-tier dialog', () => {
mockIsFreeTier.value = true
mockIsInPersonalWorkspace.value = true
const { show } = useSubscriptionDialog()
show({ reason: 'out_of_credits' })
expect(mockTrackSubscription).toHaveBeenCalledTimes(1)
expect(mockTrackSubscription).toHaveBeenCalledWith(
'modal_opened',
expect.objectContaining({ reason: 'out_of_credits' })
)
})
})
describe('startTeamWorkspaceUpgradeFlow', () => {

View File

@@ -4,8 +4,6 @@ import { useDialogStore } from '@/stores/dialogStore'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
@@ -13,8 +11,14 @@ const DIALOG_KEY = 'subscription-required'
const FREE_TIER_DIALOG_KEY = 'free-tier-info'
const RESUME_PRICING_KEY = 'comfy:resume-team-pricing'
export interface SubscriptionDialogOptions {
reason?: PaymentIntentSource
export type SubscriptionDialogReason =
| 'subscription_required'
| 'out_of_credits'
| 'top_up_blocked'
| 'deep_link'
interface SubscriptionDialogOptions {
reason?: SubscriptionDialogReason
/**
* Forces the unified pricing dialog to open on a specific plan tab,
* overriding the workspace-derived default (e.g. an "Upgrade to Team" CTA
@@ -34,17 +38,6 @@ export const useSubscriptionDialog = () => {
dialogStore.closeDialog({ key: FREE_TIER_DIALOG_KEY })
}
// Fired here — the choke point every paywall/pricing dialog variant passes
// through — so both the legacy and workspace billing paths emit it.
function trackModalOpened(reason?: PaymentIntentSource) {
// Resolved lazily to avoid the useBillingContext import cycle (see below).
const { tier } = useBillingContext()
useTelemetry()?.trackSubscription('modal_opened', {
current_tier: tier.value?.toLowerCase(),
reason
})
}
function showPricingTable(options?: SubscriptionDialogOptions) {
if (!isCloud) return
@@ -78,8 +71,6 @@ export const useSubscriptionDialog = () => {
return
}
trackModalOpened(options?.reason)
// Shared dialog shell styling for both variants.
const dialogComponentProps = {
style: 'width: min(1328px, 95vw); max-height: 958px;',
@@ -176,8 +167,6 @@ export const useSubscriptionDialog = () => {
// (not at composable setup) to avoid the useBillingContext import cycle.
const { isFreeTier } = useBillingContext()
if (isFreeTier.value && workspaceStore.isInPersonalWorkspace) {
trackModalOpened(options?.reason)
const component = defineAsyncComponent(
() =>
import('@/platform/cloud/subscription/components/FreeTierDialogContent.vue')
@@ -247,7 +236,7 @@ export const useSubscriptionDialog = () => {
sessionStorage.removeItem(RESUME_PRICING_KEY)
if (!workspaceStore.isInPersonalWorkspace) {
showPricingTable({ reason: 'team_upgrade_resume' })
showPricingTable()
}
} catch {
// sessionStorage may be unavailable

View File

@@ -1,49 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
clearPendingSubscriptionCheckoutAttempt,
consumePendingSubscriptionCheckoutSuccess,
recordPendingSubscriptionCheckoutAttempt
} from './subscriptionCheckoutTracker'
const activeProStatus = {
is_active: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY'
} as const
describe('subscriptionCheckoutTracker', () => {
beforeEach(() => {
clearPendingSubscriptionCheckoutAttempt()
})
it('round-trips payment_intent_source from attempt to success metadata', () => {
recordPendingSubscriptionCheckoutAttempt({
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new',
payment_intent_source: 'subscribe_to_run'
})
const metadata = consumePendingSubscriptionCheckoutSuccess(activeProStatus)
expect(metadata).toMatchObject({
tier: 'pro',
checkout_type: 'new',
payment_intent_source: 'subscribe_to_run'
})
})
it('omits payment_intent_source when the attempt had none', () => {
recordPendingSubscriptionCheckoutAttempt({
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new'
})
const metadata = consumePendingSubscriptionCheckoutSuccess(activeProStatus)
expect(metadata).not.toBeNull()
expect(metadata).not.toHaveProperty('payment_intent_source')
})
})

View File

@@ -7,12 +7,7 @@ import type {
TierKey
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type {
BeginCheckoutMetadata,
PaymentIntentSource,
SubscriptionCheckoutType,
SubscriptionSuccessMetadata
} from '@/platform/telemetry/types'
import type { SubscriptionSuccessMetadata } from '@/platform/telemetry/types'
const PENDING_SUBSCRIPTION_CHECKOUT_MAX_AGE_MS = 6 * 60 * 60 * 1000
const VALID_TIER_KEYS = new Set<TierKey>([
@@ -28,6 +23,7 @@ export const PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY =
export const PENDING_SUBSCRIPTION_CHECKOUT_EVENT =
'comfy:subscription-checkout-attempt-changed'
type CheckoutType = 'new' | 'change'
type SubscriptionDuration = 'MONTHLY' | 'ANNUAL'
interface SubscriptionStatusSnapshot {
@@ -36,24 +32,22 @@ interface SubscriptionStatusSnapshot {
subscription_duration?: SubscriptionDuration | null
}
export interface PendingSubscriptionCheckoutAttempt {
interface PendingSubscriptionCheckoutAttempt {
attempt_id: string
started_at_ms: number
tier: TierKey
cycle: BillingCycle
checkout_type: SubscriptionCheckoutType
checkout_type: CheckoutType
previous_tier?: TierKey
previous_cycle?: BillingCycle
payment_intent_source?: PaymentIntentSource
}
interface PendingSubscriptionCheckoutAttemptInput {
interface RecordPendingSubscriptionCheckoutAttemptInput {
tier: TierKey
cycle: BillingCycle
checkout_type: SubscriptionCheckoutType
checkout_type: CheckoutType
previous_tier?: TierKey
previous_cycle?: BillingCycle
payment_intent_source?: PaymentIntentSource
}
const dispatchPendingCheckoutChangeEvent = () => {
@@ -174,9 +168,6 @@ const normalizeAttempt = (
...(candidate.previous_cycle === 'monthly' ||
candidate.previous_cycle === 'yearly'
? { previous_cycle: candidate.previous_cycle }
: {}),
...(typeof candidate.payment_intent_source === 'string'
? { payment_intent_source: candidate.payment_intent_source }
: {})
}
}
@@ -233,27 +224,20 @@ const getPendingSubscriptionCheckoutAttempt =
export const hasPendingSubscriptionCheckoutAttempt = (): boolean =>
getPendingSubscriptionCheckoutAttempt() !== null
export const createPendingSubscriptionCheckoutAttempt = (
input: PendingSubscriptionCheckoutAttemptInput
export const recordPendingSubscriptionCheckoutAttempt = (
input: RecordPendingSubscriptionCheckoutAttemptInput
): PendingSubscriptionCheckoutAttempt => {
return {
const storage = getStorage()
const attempt: PendingSubscriptionCheckoutAttempt = {
attempt_id: createAttemptId(),
started_at_ms: Date.now(),
tier: input.tier,
cycle: input.cycle,
checkout_type: input.checkout_type,
...(input.previous_tier ? { previous_tier: input.previous_tier } : {}),
...(input.previous_cycle ? { previous_cycle: input.previous_cycle } : {}),
...(input.payment_intent_source
? { payment_intent_source: input.payment_intent_source }
: {})
...(input.previous_cycle ? { previous_cycle: input.previous_cycle } : {})
}
}
export const persistPendingSubscriptionCheckoutAttempt = (
attempt: PendingSubscriptionCheckoutAttempt
): PendingSubscriptionCheckoutAttempt => {
const storage = getStorage()
if (!storage) {
return attempt
}
@@ -271,21 +255,6 @@ export const persistPendingSubscriptionCheckoutAttempt = (
return attempt
}
export const recordPendingSubscriptionCheckoutAttempt = (
input: PendingSubscriptionCheckoutAttemptInput
): PendingSubscriptionCheckoutAttempt =>
persistPendingSubscriptionCheckoutAttempt(
createPendingSubscriptionCheckoutAttempt(input)
)
export const withPendingCheckoutAttemptId = (
metadata: BeginCheckoutMetadata,
attempt: PendingSubscriptionCheckoutAttempt
): BeginCheckoutMetadata => ({
...metadata,
checkout_attempt_id: attempt.attempt_id
})
const didAttemptSucceed = (
attempt: PendingSubscriptionCheckoutAttempt,
status: SubscriptionStatusSnapshot
@@ -318,9 +287,6 @@ export const consumePendingSubscriptionCheckoutSuccess = (
cycle: attempt.cycle,
checkout_type: attempt.checkout_type,
...(attempt.previous_tier ? { previous_tier: attempt.previous_tier } : {}),
...(attempt.payment_intent_source
? { payment_intent_source: attempt.payment_intent_source }
: {}),
value,
currency: 'USD',
ecommerce: {

View File

@@ -132,14 +132,13 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'yearly')
await performSubscriptionCheckout('pro', 'yearly', true)
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith({
user_id: 'user-123',
tier: 'pro',
cycle: 'yearly',
checkout_type: 'new',
checkout_attempt_id: expect.any(String),
ga_client_id: 'ga-client-id',
ga_session_id: 'ga-session-id',
ga_session_number: 'ga-session-number',
@@ -151,12 +150,6 @@ describe('performSubscriptionCheckout', () => {
gbraid: 'gbraid-456',
wbraid: 'wbraid-789'
})
const beginCheckoutMetadata =
mockTelemetry.trackBeginCheckout.mock.calls[0][0]
const [, storedAttempt] = mockLocalStorage.setItem.mock.calls[0]
expect(beginCheckoutMetadata.checkout_attempt_id).toBe(
JSON.parse(storedAttempt).attempt_id
)
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining(
'/customers/cloud-subscription-checkout/pro-yearly'
@@ -193,7 +186,7 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'monthly')
await performSubscriptionCheckout('pro', 'monthly', true)
expect(warnSpy).toHaveBeenCalledWith(
'[SubscriptionCheckout] Failed to collect checkout attribution',
@@ -210,43 +203,11 @@ describe('performSubscriptionCheckout', () => {
user_id: 'user-123',
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new',
checkout_attempt_id: expect.any(String)
checkout_type: 'new'
})
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
})
it('carries the payment intent source into begin_checkout and the pending attempt', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => window as unknown as Window)
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'monthly', {
paymentIntentSource: 'out_of_credits'
})
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith(
expect.objectContaining({ payment_intent_source: 'out_of_credits' })
)
const beginCheckoutMetadata =
mockTelemetry.trackBeginCheckout.mock.calls[0][0]
const [, storedAttempt] = mockLocalStorage.setItem.mock.calls[0]
const pendingAttempt = JSON.parse(storedAttempt)
expect(pendingAttempt).toMatchObject({
payment_intent_source: 'out_of_credits'
})
expect(beginCheckoutMetadata.checkout_attempt_id).toBe(
pendingAttempt.attempt_id
)
openSpy.mockRestore()
})
it('uses the latest userId when it changes after checkout starts', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi
@@ -261,7 +222,7 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
const checkoutPromise = performSubscriptionCheckout('pro', 'yearly')
const checkoutPromise = performSubscriptionCheckout('pro', 'yearly', true)
mockUserId.value = 'user-late'
authHeader.resolve({ Authorization: 'Bearer test-token' })
@@ -274,14 +235,13 @@ describe('performSubscriptionCheckout', () => {
user_id: 'user-late',
tier: 'pro',
cycle: 'yearly',
checkout_type: 'new',
checkout_attempt_id: expect.any(String)
checkout_type: 'new'
})
)
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
})
it('does not persist the pending attempt when the checkout popup is blocked', async () => {
it('does not persist a pending attempt when the checkout popup is blocked', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
@@ -290,18 +250,11 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'monthly')
await performSubscriptionCheckout('pro', 'monthly', true)
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
const storedAttempt = window.localStorage.getItem(
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY
)
expect(storedAttempt).toBeNull()
expect(mockLocalStorage.setItem).not.toHaveBeenCalled()
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith(
expect.objectContaining({
checkout_attempt_id: expect.any(String)
})
)
expect(
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
).toBeNull()
})
})

View File

@@ -4,19 +4,12 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import {
createPendingSubscriptionCheckoutAttempt,
persistPendingSubscriptionCheckoutAttempt,
withPendingCheckoutAttemptId
} from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type {
CheckoutAttributionMetadata,
PaymentIntentSource
} from '@/platform/telemetry/types'
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import type { BillingCycle } from './subscriptionTierRank'
type CheckoutTier = TierKey | `${TierKey}-yearly`
@@ -38,11 +31,6 @@ const getCheckoutAttributionForCloud =
return getCheckoutAttribution()
}
interface PerformSubscriptionCheckoutOptions {
openInNewTab?: boolean
paymentIntentSource?: PaymentIntentSource
}
/**
* Core subscription checkout logic shared between PricingTable and
* SubscriptionRedirectView. Handles:
@@ -59,12 +47,10 @@ interface PerformSubscriptionCheckoutOptions {
export async function performSubscriptionCheckout(
tierKey: TierKey,
currentBillingCycle: BillingCycle,
options: PerformSubscriptionCheckoutOptions = {}
openInNewTab: boolean = true
): Promise<void> {
if (!isCloud) return
const { openInNewTab = true, paymentIntentSource } = options
const authStore = useAuthStore()
const { userId } = storeToRefs(authStore)
const telemetry = useTelemetry()
@@ -122,29 +108,14 @@ export async function performSubscriptionCheckout(
const data = await response.json()
if (data.checkout_url) {
const pendingAttempt = createPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new',
payment_intent_source: paymentIntentSource
})
if (userId.value) {
telemetry?.trackBeginCheckout(
withPendingCheckoutAttemptId(
{
user_id: userId.value,
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new',
...(paymentIntentSource
? { payment_intent_source: paymentIntentSource }
: {}),
...checkoutAttribution
},
pendingAttempt
)
)
telemetry?.trackBeginCheckout({
user_id: userId.value,
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new',
...checkoutAttribution
})
}
if (openInNewTab) {
@@ -152,9 +123,18 @@ export async function performSubscriptionCheckout(
if (!checkoutWindow) {
return
}
persistPendingSubscriptionCheckoutAttempt(pendingAttempt)
recordPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new'
})
} else {
persistPendingSubscriptionCheckoutAttempt(pendingAttempt)
recordPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new'
})
globalThis.location.href = data.checkout_url
}
}

Some files were not shown because too many files have changed in this diff Show More