From de05790f5b9e58dfd950fba6c19bc7207d0fe16f Mon Sep 17 00:00:00 2001 From: huang47 <157390+huang47@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:41:47 -0700 Subject: [PATCH] test: drop mock-refactor files, split into follow-up PR Reverts the 8 test files whose new coverage required rewriting existing mock scaffolding, keeping this PR purely additive (no deleted lines except formatting). Rework lands separately in #13411 so it can be reviewed on its own. --- .../useSelectionToolboxPosition.test.ts | 288 +--- .../graph/useNodeMenuOptions.test.ts | 168 +- .../graph/useSubgraphOperations.test.ts | 171 +- .../maskeditor/useBrushDrawing.test.ts | 1169 ++++---------- .../node/useNodeImageUpload.test.ts | 111 +- src/composables/painter/usePainter.test.ts | 744 +-------- src/composables/useCoreCommands.test.ts | 1390 +++-------------- src/composables/useWaveAudioPlayer.test.ts | 195 +-- 8 files changed, 599 insertions(+), 3637 deletions(-) diff --git a/src/composables/canvas/useSelectionToolboxPosition.test.ts b/src/composables/canvas/useSelectionToolboxPosition.test.ts index 75615fb02c..d7ae382531 100644 --- a/src/composables/canvas/useSelectionToolboxPosition.test.ts +++ b/src/composables/canvas/useSelectionToolboxPosition.test.ts @@ -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 @@ -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, - state: Partial = {}, - ds: Partial = {} - ) { + 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 as LGraphCanvas) let toolbox: HTMLElement | undefined - let visible!: ReturnType['visible'] const TestHarness = defineComponent({ setup() { const toolboxRef = ref(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, - state: Partial = {} - ) { - 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 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['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() - }) }) diff --git a/src/composables/graph/useNodeMenuOptions.test.ts b/src/composables/graph/useNodeMenuOptions.test.ts index 973ba7df5b..a6ab22bb1e 100644 --- a/src/composables/graph/useNodeMenuOptions.test.ts +++ b/src/composables/graph/useNodeMenuOptions.test.ts @@ -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( - read: (options: ReturnType) => 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') - }) }) diff --git a/src/composables/graph/useSubgraphOperations.test.ts b/src/composables/graph/useSubgraphOperations.test.ts index 1419659f34..59abf9e8a4 100644 --- a/src/composables/graph/useSubgraphOperations.test.ts +++ b/src/composables/graph/useSubgraphOperations.test.ts @@ -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 - unpackSubgraph?: ReturnType - } - subgraph?: { - convertToSubgraph?: ReturnType - unpackSubgraph?: ReturnType - } - 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() - }) }) diff --git a/src/composables/maskeditor/useBrushDrawing.test.ts b/src/composables/maskeditor/useBrushDrawing.test.ts index dd7e9c4ded..e5244c8dbb 100644 --- a/src/composables/maskeditor/useBrushDrawing.test.ts +++ b/src/composables/maskeditor/useBrushDrawing.test.ts @@ -1,867 +1,354 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { nextTick } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { effectScope, ref } from 'vue' +import type { EffectScope } from 'vue' -import { - BrushShape, - CompositionOperation, - MaskBlendMode, - Tools -} from '@/extensions/core/maskeditor/types' -import type { Brush } from '@/extensions/core/maskeditor/types' +// vi.hoisted runs before imports — only vi.fn() is safe here (no Vue) +const saveStateSpy = vi.hoisted(() => vi.fn()) -// Patch document.createElement to return a canvas with a working 2d context -const originalCreateElement = document.createElement.bind(document) -vi.spyOn(document, 'createElement').mockImplementation( - (tag: string, options?: ElementCreationOptions) => { - const el = originalCreateElement(tag, options) - if (tag === 'canvas') { - const canvas = el as HTMLCanvasElement - const mockImageData = { - data: new Uint8ClampedArray(1024 * 4), - width: 32, - height: 32 - } - const ctx2d = { - createImageData: vi.fn(() => mockImageData), - putImageData: vi.fn(), - getImageData: vi.fn(() => mockImageData) - } - const origGetContext = canvas.getContext.bind(canvas) - canvas.getContext = ((id: string, ...rest: unknown[]) => { - if (id === '2d') return ctx2d as unknown as CanvasRenderingContext2D - return origGetContext(id as '2d', ...rest) - }) as typeof canvas.getContext - } - return el +const mockStoreDef = vi.hoisted(() => ({ + brushSettings: { + size: 20, + hardness: 0.9, + opacity: 1, + stepSize: 5, + type: 'arc' as string + }, + currentTool: 'pen' as string, + activeLayer: 'mask' as string, + maskCanvas: null as HTMLCanvasElement | null, + maskCtx: null as CanvasRenderingContext2D | null, + rgbCanvas: null as HTMLCanvasElement | null, + rgbCtx: null as CanvasRenderingContext2D | null, + maskBlendMode: 'black', + maskOpacity: 0.8, + maskColor: { r: 0, g: 0, b: 0 }, + rgbColor: '#FF0000', + canvasHistory: { saveState: saveStateSpy } +})) + +// vi.mock factory runs after hoisting — ref/computed from Vue are available +vi.mock('./useGPUResources', () => { + // Singletons shared across all calls to useGPUResources() in this test file + const isSavingHistory = ref(false) + const dirtyRect = ref({ + minX: Infinity, + minY: Infinity, + maxX: -Infinity, + maxY: -Infinity + }) + const hasRenderer = ref(false) + const previewCanvas = ref(null) + const prepareStroke = vi.fn() + const clearPreview = vi.fn() + const compositeStroke = vi.fn() + const copyGpuToCanvas = vi + .fn() + .mockResolvedValue({ maskData: undefined, rgbData: undefined }) + return { + useGPUResources: () => ({ + isSavingHistory, + dirtyRect, + hasRenderer, + previewCanvas, + prepareStroke, + clearPreview, + compositeStroke, + copyGpuToCanvas, + gpuRender: vi.fn(), + gpuDrawPoint: vi.fn(), + clearGPU: vi.fn(), + destroy: vi.fn(), + initGPUResources: vi.fn().mockResolvedValue(undefined), + initPreviewCanvas: vi.fn() + }) } -) - -// Mock dependencies that are NOT the code under test -vi.mock('@/scripts/utils', () => ({ - getStorageValue: vi.fn(), - setStorageValue: vi.fn() -})) - -vi.mock('typegpu', () => ({ - tgpu: { init: vi.fn() } -})) - -vi.mock('./gpu/GPUBrushRenderer', () => ({ - GPUBrushRenderer: vi.fn() -})) - -vi.mock('@vueuse/core', () => ({ - createSharedComposable: (fn: () => unknown) => fn -})) - -const mockScreenToCanvas = vi.fn((p) => p) +}) vi.mock('./useCoordinateTransform', () => ({ useCoordinateTransform: () => ({ - screenToCanvas: mockScreenToCanvas, - canvasToScreen: vi.fn((p) => p) + screenToCanvas: vi.fn(({ x, y }: { x: number; y: number }) => ({ x, y })) }) })) -const mockCanvasHistory = { - saveState: vi.fn(), - currentStateIndex: 0, - canUndo: { value: false }, - canRedo: { value: false }, - undo: vi.fn(), - redo: vi.fn() -} - -function createMockCtx(): CanvasRenderingContext2D { - const gradient = { - addColorStop: vi.fn() - } - return { - beginPath: vi.fn(), - fill: vi.fn(), - arc: vi.fn(), - rect: vi.fn(), - fillStyle: '', - globalCompositeOperation: 'source-over', - drawImage: vi.fn(), - getImageData: vi.fn(() => ({ - data: new Uint8ClampedArray(100 * 100 * 4), - width: 100, - height: 100 - })), - putImageData: vi.fn(), - clearRect: vi.fn(), - createRadialGradient: vi.fn(() => gradient) - } as unknown as CanvasRenderingContext2D -} - -interface MockStore { - brushSettings: Brush - maskBlendMode: MaskBlendMode - activeLayer: string - rgbColor: string - currentTool: Tools - maskColor: { r: number; g: number; b: number } - maskCanvas: HTMLCanvasElement | null - maskCtx: CanvasRenderingContext2D | null - rgbCanvas: HTMLCanvasElement | null - rgbCtx: CanvasRenderingContext2D | null - maskOpacity: number - canvasHistory: typeof mockCanvasHistory - brushVisible: boolean - brushPreviewGradientVisible: boolean - clearTrigger: number - tgpuRoot: { destroy: () => void } | null - gpuTexturesNeedRecreation: boolean - gpuTextureWidth: number - gpuTextureHeight: number - pendingGPUMaskData: null - pendingGPURgbData: null - setBrushSize: ReturnType - setBrushOpacity: ReturnType - setBrushHardness: ReturnType - setBrushStepSize: ReturnType -} - -let mockStore: MockStore - -function createMockCanvas(): HTMLCanvasElement { - const canvas = originalCreateElement('canvas') as HTMLCanvasElement - canvas.width = 100 - canvas.height = 100 - return canvas -} - -function createMockStore(): MockStore { - return { - brushSettings: { - type: BrushShape.Arc, - size: 20, - opacity: 1, - hardness: 1, - stepSize: 5 - }, - maskBlendMode: MaskBlendMode.Black, - activeLayer: 'mask', - rgbColor: '#FF0000', - currentTool: Tools.MaskPen, - maskColor: { r: 0, g: 0, b: 0 }, - maskCanvas: createMockCanvas(), - maskCtx: createMockCtx(), - rgbCanvas: createMockCanvas(), - rgbCtx: createMockCtx(), - maskOpacity: 0.8, - canvasHistory: mockCanvasHistory, - brushVisible: true, - brushPreviewGradientVisible: false, - clearTrigger: 0, - tgpuRoot: null, - gpuTexturesNeedRecreation: false, - gpuTextureWidth: 0, - gpuTextureHeight: 0, - pendingGPUMaskData: null, - pendingGPURgbData: null, - setBrushSize: vi.fn((s: number) => { - mockStore.brushSettings.size = Math.max(1, Math.min(250, s)) - }), - setBrushOpacity: vi.fn((o: number) => { - mockStore.brushSettings.opacity = Math.max(0, Math.min(1, o)) - }), - setBrushHardness: vi.fn((h: number) => { - mockStore.brushSettings.hardness = Math.max(0, Math.min(1, h)) - }), - setBrushStepSize: vi.fn((s: number) => { - mockStore.brushSettings.stepSize = Math.max(1, Math.min(100, s)) - }) - } -} - -vi.mock('@/stores/maskEditorStore', () => ({ - useMaskEditorStore: vi.fn(() => mockStore) +vi.mock('./useBrushPersistence', () => ({ + useBrushPersistence: () => ({ loadAndApply: vi.fn(), save: vi.fn() }) })) -// Must import AFTER mocks -import { getStorageValue, setStorageValue } from '@/scripts/utils' +vi.mock('./useBrushAdjustment', () => ({ + useBrushAdjustment: () => ({ + startBrushAdjustment: vi.fn(), + handleBrushAdjustment: vi.fn() + }) +})) + +vi.mock('@/stores/maskEditorStore', () => ({ + useMaskEditorStore: vi.fn(() => mockStoreDef) +})) + +vi.mock('@/scripts/app', () => ({ + app: { registerExtension: vi.fn() } +})) + +import { useGPUResources } from './useGPUResources' import { useBrushDrawing } from './useBrushDrawing' -function createPointerEvent( - overrides: Partial = {} +function makePointerEvent( + x: number, + y: number, + opts: { buttons?: number; shiftKey?: boolean } = {} ): PointerEvent { return { - offsetX: 50, - offsetY: 50, - buttons: 1, - shiftKey: false, - preventDefault: vi.fn(), - ...overrides + offsetX: x, + offsetY: y, + buttons: opts.buttons ?? 1, + shiftKey: opts.shiftKey ?? false, + preventDefault: vi.fn() } as unknown as PointerEvent } -describe('useBrushDrawing', () => { - beforeEach(() => { - vi.clearAllMocks() - mockStore = createMockStore() - vi.mocked(getStorageValue).mockReturnValue(null) +function makeMockCtx(): CanvasRenderingContext2D { + const gradient = { addColorStop: vi.fn() } + return { + beginPath: vi.fn(), + fill: vi.fn(), + rect: vi.fn(), + arc: vi.fn(), + fillStyle: '', + drawImage: vi.fn(), + createRadialGradient: vi.fn(() => gradient), + globalCompositeOperation: 'source-over' + } as unknown as CanvasRenderingContext2D +} + +let scope: EffectScope | null = null + +function setup() { + scope = effectScope() + return scope.run(() => useBrushDrawing())! +} + +beforeEach(() => { + vi.clearAllMocks() + + const mockCtx = makeMockCtx() + const mockCanvas = { + width: 200, + height: 200, + style: { opacity: '' } + } as unknown as HTMLCanvasElement + + mockStoreDef.maskCanvas = mockCanvas + mockStoreDef.maskCtx = mockCtx + mockStoreDef.rgbCanvas = mockCanvas + mockStoreDef.rgbCtx = mockCtx + mockStoreDef.currentTool = 'pen' + mockStoreDef.activeLayer = 'mask' + + const gpu = useGPUResources() + gpu.isSavingHistory.value = false + gpu.hasRenderer.value = false + gpu.previewCanvas.value = null + gpu.dirtyRect.value = { + minX: Infinity, + minY: Infinity, + maxX: -Infinity, + maxY: -Infinity + } +}) + +afterEach(() => { + scope?.stop() + scope = null +}) + +describe('startDrawing', () => { + it('calls prepareStroke on the GPU resources', async () => { + const { startDrawing } = setup() + await startDrawing(makePointerEvent(50, 50)) + expect(useGPUResources().prepareStroke).toHaveBeenCalledOnce() }) - describe('initialization', () => { - it('should restore brush settings from cache when available', () => { - const cached: Brush = { - type: BrushShape.Rect, - size: 42, - opacity: 0.5, - hardness: 0.8, - stepSize: 15 - } - vi.mocked(getStorageValue).mockReturnValue(JSON.stringify(cached)) - - useBrushDrawing() - - expect(mockStore.setBrushSize).toHaveBeenCalledWith(42) - expect(mockStore.setBrushOpacity).toHaveBeenCalledWith(0.5) - expect(mockStore.setBrushHardness).toHaveBeenCalledWith(0.8) - expect(mockStore.setBrushStepSize).toHaveBeenCalledWith(15) - expect(mockStore.brushSettings.type).toBe(BrushShape.Rect) - }) - - it('should not modify store when no cached settings exist', () => { - vi.mocked(getStorageValue).mockReturnValue(null) - - useBrushDrawing() - - expect(mockStore.setBrushSize).not.toHaveBeenCalled() - expect(mockStore.setBrushOpacity).not.toHaveBeenCalled() - }) - - it('should handle corrupted cache gracefully', () => { - vi.mocked(getStorageValue).mockReturnValue('{invalid json') - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - useBrushDrawing() - - expect(consoleSpy).toHaveBeenCalledWith( - 'Failed to load brush from cache:', - expect.any(SyntaxError) - ) - expect(mockStore.setBrushSize).not.toHaveBeenCalled() - }) - - it('should accept initial settings overrides', async () => { - const { startBrushAdjustment, handleBrushAdjustment } = useBrushDrawing({ - useDominantAxis: true, - brushAdjustmentSpeed: 2.0 - }) - - await startBrushAdjustment( - createPointerEvent({ offsetX: 50, offsetY: 50 }) - ) - await handleBrushAdjustment( - createPointerEvent({ offsetX: 100, offsetY: 52 }) - ) - - expect(mockStore.setBrushSize).toHaveBeenCalled() - }) + it('sets DestinationOut composition when tool is eraser', async () => { + mockStoreDef.currentTool = 'eraser' + const { startDrawing } = setup() + await startDrawing(makePointerEvent(50, 50)) + expect(mockStoreDef.maskCtx!.globalCompositeOperation).toBe( + 'destination-out' + ) }) - describe('saveBrushSettings', () => { - it('should debounce-save current brush settings to storage', () => { - vi.useFakeTimers() - const { saveBrushSettings } = useBrushDrawing() - - saveBrushSettings() - vi.advanceTimersByTime(300) - - expect(setStorageValue).toHaveBeenCalledWith( - 'maskeditor_brush_settings', - JSON.stringify(mockStore.brushSettings) - ) - vi.useRealTimers() - }) + it('sets SourceOver composition when tool is mask pen', async () => { + const { startDrawing } = setup() + await startDrawing(makePointerEvent(50, 50)) + expect(mockStoreDef.maskCtx!.globalCompositeOperation).toBe('source-over') }) - describe('startDrawing', () => { - it('should set isDrawing state and initialize stroke', async () => { - const { startDrawing, drawEnd } = useBrushDrawing() - const event = createPointerEvent() - - await startDrawing(event) - - // Verify initShape was called (context globalCompositeOperation set) - expect(mockStore.maskCtx!.beginPath).toHaveBeenCalled() - - // Clean up - await drawEnd(createPointerEvent()) - }) - - it('should use destination-out composition for eraser tool', async () => { - mockStore.currentTool = Tools.Eraser - const { startDrawing, drawEnd } = useBrushDrawing() - const event = createPointerEvent() - - await startDrawing(event) - - expect(mockStore.maskCtx!.globalCompositeOperation).toBe( - CompositionOperation.DestinationOut - ) - await drawEnd(createPointerEvent()) - }) - - it('should use destination-out for right mouse button', async () => { - const { startDrawing, drawEnd } = useBrushDrawing() - const event = createPointerEvent({ buttons: 2 }) - - await startDrawing(event) - - expect(mockStore.maskCtx!.globalCompositeOperation).toBe( - CompositionOperation.DestinationOut - ) - await drawEnd(createPointerEvent()) - }) - - it('should use source-over for regular drawing', async () => { - mockStore.currentTool = Tools.MaskPen - const { startDrawing, drawEnd } = useBrushDrawing() - const event = createPointerEvent() - - await startDrawing(event) - - expect(mockStore.maskCtx!.globalCompositeOperation).toBe( - CompositionOperation.SourceOver - ) - await drawEnd(createPointerEvent()) - }) - }) - - describe('drawEnd', () => { - it('should save canvas history on stroke end', async () => { - const { startDrawing, drawEnd } = useBrushDrawing() - - await startDrawing(createPointerEvent()) - await drawEnd(createPointerEvent()) - await nextTick() - - expect(mockCanvasHistory.saveState).toHaveBeenCalled() - }) - - it('should update lineStartPoint for shift-click continuity', async () => { - const { startDrawing, drawEnd } = useBrushDrawing() - - await startDrawing(createPointerEvent({ offsetX: 10, offsetY: 20 })) - await drawEnd(createPointerEvent({ offsetX: 30, offsetY: 40 })) - - // Next shift-click should reference previous end point - const shiftEvent = createPointerEvent({ - offsetX: 60, - offsetY: 80, - shiftKey: true - }) - await startDrawing(shiftEvent) - await drawEnd(createPointerEvent({ offsetX: 60, offsetY: 80 })) - - // Should have saved state for both strokes - expect(mockCanvasHistory.saveState).toHaveBeenCalledTimes(2) - }) - - it('should not save state when not currently drawing', async () => { - const { drawEnd } = useBrushDrawing() - - await drawEnd(createPointerEvent()) - - expect(mockCanvasHistory.saveState).not.toHaveBeenCalled() - }) - }) - - describe('shift+click line drawing', () => { - it('should draw a line from previous point on shift+click', async () => { - const { startDrawing, drawEnd } = useBrushDrawing() - - // First stroke establishes lineStartPoint - await startDrawing(createPointerEvent({ offsetX: 0, offsetY: 0 })) - await drawEnd(createPointerEvent({ offsetX: 0, offsetY: 0 })) - - // Shift+click should draw a line from previous end point - const shiftEvent = createPointerEvent({ - offsetX: 50, - offsetY: 0, - shiftKey: true - }) - await startDrawing(shiftEvent) - - // drawShape (via drawLine) calls fill on the context - expect(mockStore.maskCtx!.fill).toHaveBeenCalled() - - await drawEnd(createPointerEvent({ offsetX: 50, offsetY: 0 })) - }) - }) - - describe('startBrushAdjustment', () => { - it('should enable brush preview and set initial point', async () => { - const { startBrushAdjustment } = useBrushDrawing() - const event = createPointerEvent({ offsetX: 100, offsetY: 200 }) - - await startBrushAdjustment(event) - - expect(mockStore.brushPreviewGradientVisible).toBe(true) - expect(event.preventDefault).toHaveBeenCalled() - }) - }) - - describe('handleBrushAdjustment', () => { - it('should adjust brush size with horizontal movement', async () => { - const { startBrushAdjustment, handleBrushAdjustment } = useBrushDrawing() - - await startBrushAdjustment( - createPointerEvent({ offsetX: 50, offsetY: 50 }) - ) - await handleBrushAdjustment( - createPointerEvent({ offsetX: 100, offsetY: 50 }) - ) - - expect(mockStore.setBrushSize).toHaveBeenCalled() - }) - - it('should adjust brush hardness with vertical movement', async () => { - const { startBrushAdjustment, handleBrushAdjustment } = useBrushDrawing() - - await startBrushAdjustment( - createPointerEvent({ offsetX: 50, offsetY: 50 }) - ) - await handleBrushAdjustment( - createPointerEvent({ offsetX: 50, offsetY: 150 }) - ) - - expect(mockStore.setBrushHardness).toHaveBeenCalled() - }) - - it('should not adjust when no initial point is set', async () => { - const { handleBrushAdjustment } = useBrushDrawing() - - await handleBrushAdjustment( - createPointerEvent({ offsetX: 100, offsetY: 100 }) - ) - - expect(mockStore.setBrushSize).not.toHaveBeenCalled() - expect(mockStore.setBrushHardness).not.toHaveBeenCalled() - }) - - it('should apply dead zone for small movements', async () => { - const { startBrushAdjustment, handleBrushAdjustment } = useBrushDrawing() - - await startBrushAdjustment( - createPointerEvent({ offsetX: 50, offsetY: 50 }) - ) - // Move less than dead zone (5px) - await handleBrushAdjustment( - createPointerEvent({ offsetX: 53, offsetY: 53 }) - ) - - // Size should be set but with delta=0 (dead zone), so stays the same - expect(mockStore.setBrushSize).toHaveBeenCalled() - const sizeCall = mockStore.setBrushSize.mock.calls[0]![0] - expect(sizeCall).toBeCloseTo(mockStore.brushSettings.size, 0) - }) - - it('should suppress one axis when useDominantAxis is enabled', async () => { - const { startBrushAdjustment, handleBrushAdjustment } = useBrushDrawing({ - useDominantAxis: true - }) - - await startBrushAdjustment( - createPointerEvent({ offsetX: 50, offsetY: 50 }) - ) - // Move strongly horizontal - await handleBrushAdjustment( - createPointerEvent({ offsetX: 100, offsetY: 52 }) - ) - - // Size should change, hardness should stay roughly the same - expect(mockStore.setBrushSize).toHaveBeenCalled() - expect(mockStore.setBrushHardness).toHaveBeenCalled() - const hardnessCall = mockStore.setBrushHardness.mock.calls[0]![0] - expect(hardnessCall).toBeCloseTo(1, 1) - }) - - it('should cap delta values at ±100', async () => { - const { startBrushAdjustment, handleBrushAdjustment } = useBrushDrawing() - - await startBrushAdjustment( - createPointerEvent({ offsetX: 50, offsetY: 50 }) - ) - // Move extremely far - await handleBrushAdjustment( - createPointerEvent({ offsetX: 500, offsetY: 500 }) - ) - - // Should be capped, not exceed 500 size - expect(mockStore.setBrushSize).toHaveBeenCalled() - const sizeCall = mockStore.setBrushSize.mock.calls[0]![0] - expect(sizeCall).toBeLessThanOrEqual(500) - }) - - it('should respect brushAdjustmentSpeed', async () => { - const slow = useBrushDrawing({ brushAdjustmentSpeed: 0.5 }) - const fast = useBrushDrawing({ brushAdjustmentSpeed: 2.0 }) - - // Reset store between calls - const startEvent = createPointerEvent({ offsetX: 50, offsetY: 50 }) - const moveEvent = createPointerEvent({ offsetX: 100, offsetY: 50 }) - - await slow.startBrushAdjustment(startEvent) - await slow.handleBrushAdjustment(moveEvent) - expect(mockStore.setBrushSize).toHaveBeenCalled() - const slowSizeCall = mockStore.setBrushSize.mock.calls[0]![0] - - mockStore.setBrushSize.mockClear() - mockStore.brushSettings.size = 20 - - await fast.startBrushAdjustment(startEvent) - await fast.handleBrushAdjustment(moveEvent) - expect(mockStore.setBrushSize).toHaveBeenCalled() - const fastSizeCall = mockStore.setBrushSize.mock.calls[0]![0] - - // Faster speed should produce a larger change - expect(Math.abs(fastSizeCall - 20)).toBeGreaterThan( - Math.abs(slowSizeCall - 20) - ) - }) - }) - - describe('drawShape (CPU fallback)', () => { - // StrokeProcessor buffers points and only outputs after 4 control points. - // A single-point stroke is flushed in drawEnd via endStroke(). - // We test that the CPU fallback draws shapes by completing a full stroke. - - it('should draw mask shape with circle brush on stroke end', async () => { - mockStore.brushSettings.type = BrushShape.Arc - mockStore.brushSettings.hardness = 1 - - const { startDrawing, drawEnd } = useBrushDrawing() - await startDrawing(createPointerEvent({ offsetX: 50, offsetY: 50 })) - await drawEnd(createPointerEvent({ offsetX: 50, offsetY: 50 })) - - // endStroke flushes the single point via drawShape -> arc - expect(mockStore.maskCtx!.arc).toHaveBeenCalled() - }) - - it('should draw mask shape with rect brush on stroke end', async () => { - mockStore.brushSettings.type = BrushShape.Rect - mockStore.brushSettings.hardness = 1 - - const { startDrawing, drawEnd } = useBrushDrawing() - await startDrawing(createPointerEvent({ offsetX: 50, offsetY: 50 })) - await drawEnd(createPointerEvent({ offsetX: 50, offsetY: 50 })) - - // endStroke flushes the single point via drawShape -> rect - expect(mockStore.maskCtx!.rect).toHaveBeenCalled() - }) - - it('should draw on RGB layer when tool is PaintPen', async () => { - mockStore.currentTool = Tools.PaintPen - mockStore.activeLayer = 'rgb' - mockStore.brushSettings.hardness = 1 - - const { startDrawing, drawEnd } = useBrushDrawing() - await startDrawing(createPointerEvent({ offsetX: 50, offsetY: 50 })) - - // initShape is called which calls beginPath on both contexts - expect(mockStore.rgbCtx!.beginPath).toHaveBeenCalled() - - await drawEnd(createPointerEvent({ offsetX: 50, offsetY: 50 })) - }) - - it('should catch error and reset state when contexts are missing', async () => { - mockStore.maskCtx = null - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - const { startDrawing } = useBrushDrawing() - await startDrawing(createPointerEvent()) - - // startDrawing catches the error and resets isDrawing - expect(consoleSpy).toHaveBeenCalledWith( - '[useBrushDrawing] Failed to start drawing:', - expect.any(Error) - ) - }) - - it('should catch error when the RGB context is missing', async () => { - mockStore.rgbCtx = null - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - const { startDrawing, drawEnd } = useBrushDrawing() - await startDrawing(createPointerEvent()) - await drawEnd(createPointerEvent()) - - expect(consoleSpy).toHaveBeenCalledWith( - '[useBrushDrawing] Failed to start drawing:', - expect.any(Error) - ) - expect(mockCanvasHistory.saveState).not.toHaveBeenCalled() - - consoleSpy.mockRestore() - }) - - it('should use gradient for soft circle brushes on stroke end', async () => { - mockStore.brushSettings.hardness = 0.5 - mockStore.brushSettings.type = BrushShape.Arc - - const { startDrawing, drawEnd } = useBrushDrawing() - await startDrawing(createPointerEvent({ offsetX: 50, offsetY: 50 })) - await drawEnd(createPointerEvent({ offsetX: 50, offsetY: 50 })) - - expect(mockStore.maskCtx!.createRadialGradient).toHaveBeenCalled() - }) - - it('should use cached brush texture for soft rect brushes on stroke end', async () => { - mockStore.brushSettings.hardness = 0.5 - mockStore.brushSettings.type = BrushShape.Rect - - const { startDrawing, drawEnd } = useBrushDrawing() - await startDrawing(createPointerEvent({ offsetX: 50, offsetY: 50 })) - await drawEnd(createPointerEvent({ offsetX: 50, offsetY: 50 })) - - // Soft rect brush uses drawImage with cached texture - expect(mockStore.maskCtx!.drawImage).toHaveBeenCalled() - }) - }) - - describe('eraser behavior', () => { - it('should use white color when erasing on mask layer', async () => { - mockStore.currentTool = Tools.Eraser - mockStore.brushSettings.hardness = 1 - - const { startDrawing, drawEnd } = useBrushDrawing() - await startDrawing(createPointerEvent({ offsetX: 50, offsetY: 50 })) - await drawEnd(createPointerEvent({ offsetX: 50, offsetY: 50 })) - - // After stroke flush, fillStyle should have been set to white for erasing - const fillStyle = mockStore.maskCtx!.fillStyle as string - expect(fillStyle).toContain('rgba(255, 255, 255') - }) - - it('should erase on RGB layer with eraser tool', async () => { - mockStore.currentTool = Tools.Eraser - mockStore.activeLayer = 'rgb' - mockStore.brushSettings.hardness = 1 - - const { startDrawing, drawEnd } = useBrushDrawing() - await startDrawing(createPointerEvent()) - - expect(mockStore.rgbCtx!.globalCompositeOperation).toBe( - CompositionOperation.DestinationOut - ) - - await drawEnd(createPointerEvent()) - }) - }) - - describe('handleDrawing', () => { - beforeEach(() => { - vi.stubGlobal( - 'requestAnimationFrame', - (callback: FrameRequestCallback): number => { - callback(0) - return 1 - } - ) - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('should ignore hover drawing when no stroke is active', async () => { - const performanceSpy = vi - .spyOn(performance, 'now') - .mockReturnValue(Date.now() + 100) - const { handleDrawing } = useBrushDrawing() - - await handleDrawing(createPointerEvent()) - - expect(mockStore.maskCtx!.beginPath).not.toHaveBeenCalled() - - performanceSpy.mockRestore() - }) - - it('should ignore low-latency hover drawing when no stroke is active', async () => { - const { handleDrawing } = useBrushDrawing() - - await handleDrawing(createPointerEvent()) - await Promise.resolve() - - expect(mockStore.maskCtx!.beginPath).not.toHaveBeenCalled() - }) - - it('should draw delayed hover points if a stroke starts before the frame runs', async () => { - let callback: FrameRequestCallback | undefined - vi.stubGlobal( - 'requestAnimationFrame', - (frameCallback: FrameRequestCallback): number => { - callback = frameCallback - return 1 - } - ) - const performanceSpy = vi - .spyOn(performance, 'now') - .mockReturnValue(Date.now() + 100) - const drawing = useBrushDrawing() - - await drawing.handleDrawing( - createPointerEvent({ offsetX: 10, offsetY: 10 }) - ) - await drawing.startDrawing( - createPointerEvent({ offsetX: 20, offsetY: 20 }) - ) - mockStore.maskCtx!.beginPath = vi.fn() - callback?.(0) - await Promise.resolve() - - expect(mockStore.maskCtx!.beginPath).toHaveBeenCalled() - - await drawing.drawEnd(createPointerEvent({ offsetX: 20, offsetY: 20 })) - performanceSpy.mockRestore() - }) - - it('should continue a source-over stroke while drawing', async () => { - const { startDrawing, handleDrawing, drawEnd } = useBrushDrawing() - - await startDrawing(createPointerEvent()) - mockStore.maskCtx!.beginPath = vi.fn() - await handleDrawing(createPointerEvent({ offsetX: 60, offsetY: 60 })) - await Promise.resolve() - - expect(mockStore.maskCtx!.globalCompositeOperation).toBe( - CompositionOperation.SourceOver - ) - expect(mockStore.maskCtx!.beginPath).toHaveBeenCalled() - - await drawEnd(createPointerEvent()) - }) - - it('should continue an erasing stroke while drawing', async () => { - mockStore.currentTool = Tools.Eraser - const { startDrawing, handleDrawing, drawEnd } = useBrushDrawing() - - await startDrawing(createPointerEvent()) - await handleDrawing(createPointerEvent({ offsetX: 60, offsetY: 60 })) - await Promise.resolve() - - expect(mockStore.maskCtx!.globalCompositeOperation).toBe( - CompositionOperation.DestinationOut - ) - - await drawEnd(createPointerEvent()) - }) - - it('should erase while drawing with the secondary pointer button', async () => { - const { startDrawing, handleDrawing, drawEnd } = useBrushDrawing() - - await startDrawing(createPointerEvent()) - await handleDrawing( - createPointerEvent({ buttons: 2, offsetX: 60, offsetY: 60 }) - ) - await Promise.resolve() - - expect(mockStore.maskCtx!.globalCompositeOperation).toBe( - CompositionOperation.DestinationOut - ) - - await drawEnd(createPointerEvent()) - }) - - it('should log drawing errors when contexts disappear during a stroke', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - const { startDrawing, handleDrawing } = useBrushDrawing() - - await startDrawing(createPointerEvent()) - mockStore.maskCtx = null - await handleDrawing(createPointerEvent({ offsetX: 60, offsetY: 60 })) - await Promise.resolve() - - expect(consoleSpy).toHaveBeenCalledWith( - '[useBrushDrawing] Drawing error:', - expect.any(Error) - ) - - consoleSpy.mockRestore() - }) - }) - - describe('canvas visibility restoration', () => { - it('should finish mask strokes when the mask canvas element is absent', async () => { - mockStore.maskCanvas = null - const { startDrawing, drawEnd } = useBrushDrawing() - - await startDrawing(createPointerEvent()) - await drawEnd(createPointerEvent()) - - expect(mockCanvasHistory.saveState).toHaveBeenCalled() - }) - - it('should restore the RGB canvas after drawing on the RGB layer', async () => { - mockStore.activeLayer = 'rgb' - mockStore.currentTool = Tools.PaintPen - mockStore.rgbCanvas!.style.opacity = '0' - const { startDrawing, drawEnd } = useBrushDrawing() - - await startDrawing(createPointerEvent()) - await drawEnd(createPointerEvent()) - - expect(mockStore.rgbCanvas!.style.opacity).toBe('1') - }) - }) - - describe('destroy', () => { - it('should clean up tgpuRoot', () => { - const mockTgpuRoot = { destroy: vi.fn() } - mockStore.tgpuRoot = mockTgpuRoot as unknown as MockStore['tgpuRoot'] - - const { destroy } = useBrushDrawing() - destroy() - - expect(mockTgpuRoot.destroy).toHaveBeenCalled() - expect(mockStore.tgpuRoot).toBeNull() - }) - - it('should handle destroy when no GPU resources exist', () => { - const { destroy } = useBrushDrawing() - - expect(() => destroy()).not.toThrow() - }) - }) - - describe('initGPUResources', () => { - it('should warn and return when tgpu fails to init', async () => { - const { tgpu } = await import('typegpu') - vi.mocked(tgpu.init).mockRejectedValue(new Error('No WebGPU')) - - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { initGPUResources } = useBrushDrawing() - await initGPUResources() - - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Failed to initialize TypeGPU'), - expect.any(String) - ) - }) - - it('should skip setup when canvas contexts are not ready', async () => { - const { tgpu } = await import('typegpu') - const mockRoot = { device: {}, destroy: vi.fn() } - vi.mocked(tgpu.init).mockResolvedValue( - mockRoot as unknown as Awaited> - ) - - mockStore.maskCanvas = null - - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { initGPUResources } = useBrushDrawing() - await initGPUResources() - - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Canvas contexts not ready') - ) - }) + it('sets DestinationOut composition when right mouse button is used', async () => { + const { startDrawing } = setup() + await startDrawing(makePointerEvent(50, 50, { buttons: 2 })) + expect(mockStoreDef.maskCtx!.globalCompositeOperation).toBe( + 'destination-out' + ) + }) +}) + +describe('startDrawing error handling', () => { + it('catches initShape errors and resets drawing state', async () => { + mockStoreDef.maskCtx = null + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { startDrawing } = setup() + await startDrawing(makePointerEvent(50, 50)) + expect(consoleSpy).toHaveBeenCalledWith( + '[useBrushDrawing] Failed to start drawing:', + expect.any(Error) + ) + expect(mockStoreDef.maskCtx).toBeNull() + consoleSpy.mockRestore() + }) +}) + +describe('startDrawing shift+click', () => { + it('draws a line from the previous point when shift is held', async () => { + const { startDrawing } = setup() + await startDrawing(makePointerEvent(50, 50)) + await startDrawing(makePointerEvent(100, 50, { shiftKey: true })) + expect( + (mockStoreDef.maskCtx as unknown as ReturnType) + .beginPath + ).toHaveBeenCalled() + }) +}) + +describe('handleDrawing', () => { + it('updates smoothingLastDrawTime after each move event', async () => { + const rafSpy = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((cb) => { + cb(0) + return 0 + }) + const { startDrawing, handleDrawing } = setup() + await startDrawing(makePointerEvent(50, 50)) + await handleDrawing(makePointerEvent(55, 55)) + expect(rafSpy).toHaveBeenCalled() + rafSpy.mockRestore() + }) + + it('sets DestinationOut composition when tool is eraser during move', async () => { + mockStoreDef.currentTool = 'eraser' + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + cb(0) + return 0 + }) + const { startDrawing, handleDrawing } = setup() + await startDrawing(makePointerEvent(50, 50)) + await handleDrawing(makePointerEvent(55, 55)) + expect(mockStoreDef.maskCtx!.globalCompositeOperation).toBe( + 'destination-out' + ) + vi.restoreAllMocks() + }) + + it('sets DestinationOut composition when right mouse button held during move', async () => { + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + cb(0) + return 0 + }) + const { startDrawing, handleDrawing } = setup() + await startDrawing(makePointerEvent(50, 50)) + await handleDrawing(makePointerEvent(55, 55, { buttons: 2 })) + expect(mockStoreDef.maskCtx!.globalCompositeOperation).toBe( + 'destination-out' + ) + vi.restoreAllMocks() + }) +}) + +describe('drawEnd canvas visibility', () => { + it('restores rgb canvas opacity when activeLayer is rgb', async () => { + mockStoreDef.activeLayer = 'rgb' + const mockRgbCanvas = { + width: 200, + height: 200, + style: { opacity: '' } + } as unknown as HTMLCanvasElement + mockStoreDef.rgbCanvas = mockRgbCanvas + const { startDrawing, drawEnd } = setup() + await startDrawing(makePointerEvent(50, 50)) + await drawEnd(makePointerEvent(60, 60)) + expect(mockRgbCanvas.style.opacity).toBe('1') + }) + + it('restores preview canvas opacity to 1 after drawEnd', async () => { + const gpu = useGPUResources() + const mockPreviewCanvas = { + style: { opacity: '' } + } as unknown as HTMLCanvasElement + gpu.previewCanvas.value = mockPreviewCanvas + const { startDrawing, drawEnd } = setup() + await startDrawing(makePointerEvent(50, 50)) + await drawEnd(makePointerEvent(60, 60)) + expect(mockPreviewCanvas.style.opacity).toBe('1') + }) +}) + +describe('drawEnd', () => { + it('calls compositeStroke indicating the active layer and erasing state', async () => { + const { startDrawing, drawEnd } = setup() + await startDrawing(makePointerEvent(50, 50)) + await drawEnd(makePointerEvent(60, 60)) + expect(useGPUResources().compositeStroke).toHaveBeenCalledOnce() + expect(useGPUResources().compositeStroke).toHaveBeenCalledWith(false, false) + }) + + it('passes isRgb=true to compositeStroke when active layer is rgb', async () => { + mockStoreDef.activeLayer = 'rgb' + const { startDrawing, drawEnd } = setup() + await startDrawing(makePointerEvent(50, 50)) + await drawEnd(makePointerEvent(60, 60)) + expect(useGPUResources().compositeStroke).toHaveBeenCalledWith(true, false) + }) + + it('passes isErasing=true to compositeStroke when tool is eraser', async () => { + mockStoreDef.currentTool = 'eraser' + const { startDrawing, drawEnd } = setup() + await startDrawing(makePointerEvent(50, 50)) + await drawEnd(makePointerEvent(60, 60)) + expect(useGPUResources().compositeStroke).toHaveBeenCalledWith(false, true) + }) + + it('restores mask canvas opacity after drawing on mask layer', async () => { + mockStoreDef.activeLayer = 'mask' + const mockMaskCanvas = { + width: 200, + height: 200, + style: { opacity: '' } + } as unknown as HTMLCanvasElement + mockStoreDef.maskCanvas = mockMaskCanvas + const { startDrawing, drawEnd } = setup() + await startDrawing(makePointerEvent(50, 50)) + await drawEnd(makePointerEvent(60, 60)) + expect(mockMaskCanvas.style.opacity).toBe(String(mockStoreDef.maskOpacity)) + }) + + it('calls clearPreview to clean up the GPU overlay', async () => { + const { startDrawing, drawEnd } = setup() + await startDrawing(makePointerEvent(50, 50)) + await drawEnd(makePointerEvent(60, 60)) + expect(useGPUResources().clearPreview).toHaveBeenCalledOnce() + }) + + it('saves canvas history on stroke completion', async () => { + const { startDrawing, drawEnd } = setup() + await startDrawing(makePointerEvent(50, 50)) + await drawEnd(makePointerEvent(60, 60)) + expect(saveStateSpy).toHaveBeenCalledOnce() + }) + + it('is a no-op when drawing was never started', async () => { + const { drawEnd } = setup() + await drawEnd(makePointerEvent(60, 60)) + expect(useGPUResources().compositeStroke).not.toHaveBeenCalled() + expect(saveStateSpy).not.toHaveBeenCalled() }) }) diff --git a/src/composables/node/useNodeImageUpload.test.ts b/src/composables/node/useNodeImageUpload.test.ts index 5977794e0c..59a788c064 100644 --- a/src/composables/node/useNodeImageUpload.test.ts +++ b/src/composables/node/useNodeImageUpload.test.ts @@ -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 -let capturedResultItemDrop: (item: ResultItem) => void -let capturedPasteOnPaste: (files: File[]) => Promise -let capturedFileInputOnSelect: (files: File[]) => Promise -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({ - 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') - }) }) diff --git a/src/composables/painter/usePainter.test.ts b/src/composables/painter/usePainter.test.ts index 52fda3ab00..6bb10a8786 100644 --- a/src/composables/painter/usePainter.test.ts +++ b/src/composables/painter/usePainter.test.ts @@ -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 = {} 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 - } = {} -) { - 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) - }) - 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) - }) }) }) diff --git a/src/composables/useCoreCommands.test.ts b/src/composables/useCoreCommands.test.ts index a82bbc2d0a..b5f3163835 100644 --- a/src/composables/useCoreCommands.test.ts +++ b/src/composables/useCoreCommands.test.ts @@ -3,15 +3,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { ref } from 'vue' import { useCoreCommands } from '@/composables/useCoreCommands' -import { - LGraphGroup, - LGraphNode, - LiteGraph -} from '@/lib/litegraph/src/litegraph' +import { useExternalLink } from '@/composables/useExternalLink' +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import { useSettingStore } from '@/platform/settings/settingStore' import { api } from '@/scripts/api' import { app } from '@/scripts/app' +import type * as ModelStoreModule from '@/stores/modelStore' import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils' +import { fromPartial } from '@total-typescript/shoehorn' // Mock vue-i18n for useExternalLink const mockLocale = ref('en') @@ -38,34 +38,17 @@ vi.mock('@/scripts/app', () => { const mockCanvas = { subgraph: undefined, selectedItems: new Set(), - selected_nodes: null as Record | null, copyToClipboard: vi.fn(), pasteFromClipboard: vi.fn(), selectItems: vi.fn(), ds: mockDs, - deleteSelected: vi.fn(), - setDirty: vi.fn(), - fitViewToSelectionAnimated: vi.fn(), - empty: false, - state: { - readOnly: false, - selectionChanged: false - }, - graph: { - add: vi.fn(), - convertToSubgraph: vi.fn(), - rootGraph: {} - }, - select: vi.fn(), - canvas: { - dispatchEvent: vi.fn() - }, - setGraph: vi.fn() + setDirty: vi.fn() } return { app: { clean: vi.fn(() => { + // Simulate app.clean() calling graph.clear() only when not in subgraph if (!mockCanvas.subgraph) { mockGraphClear() } @@ -74,11 +57,8 @@ vi.mock('@/scripts/app', () => { refreshComboInNodes: vi.fn().mockResolvedValue(undefined), canvas: mockCanvas, rootGraph: { - clear: mockGraphClear, - _nodes: [] - }, - queuePrompt: vi.fn(), - ui: { loadFile: vi.fn() } + clear: mockGraphClear + } } } }) @@ -86,12 +66,19 @@ vi.mock('@/scripts/app', () => { vi.mock('@/scripts/api', () => ({ api: { dispatchCustomEvent: vi.fn(), - apiURL: vi.fn(() => 'http://localhost:8188'), - interrupt: vi.fn(), - freeMemory: vi.fn() + apiURL: vi.fn(() => 'http://localhost:8188') } })) +const mockModelStoreRefresh = vi.fn().mockResolvedValue(undefined) +vi.mock('@/stores/modelStore', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useModelStore: () => ({ refresh: mockModelStoreRefresh }) + } +}) + vi.mock('@/platform/settings/settingStore') vi.mock('@/stores/authStore', () => ({ @@ -108,25 +95,11 @@ vi.mock('firebase/auth', () => ({ onAuthStateChanged: vi.fn() })) -const mockWorkflowService = vi.hoisted(() => ({ - closeWorkflow: vi.fn(), - duplicateWorkflow: vi.fn(), - exportWorkflow: vi.fn(), - loadBlankWorkflow: vi.fn(), - loadDefaultWorkflow: vi.fn(), - loadNextOpenedWorkflow: vi.fn(), - loadPreviousOpenedWorkflow: vi.fn(), - reloadCurrentWorkflow: vi.fn(), - renameWorkflow: vi.fn(), - saveWorkflow: vi.fn(), - saveWorkflowAs: vi.fn() -})) vi.mock('@/platform/workflow/core/services/workflowService', () => ({ - useWorkflowService: vi.fn(() => mockWorkflowService) + useWorkflowService: vi.fn(() => ({})) })) const mockDialogService = vi.hoisted(() => ({ - confirm: vi.fn(), prompt: vi.fn() })) vi.mock('@/services/dialogService', () => ({ @@ -140,27 +113,10 @@ vi.mock('@/services/litegraphService', () => ({ })) })) -const mockTelemetry = vi.hoisted(() => ({ - trackWorkflowCreated: vi.fn(), - trackRunButton: vi.fn(), - trackWorkflowExecution: vi.fn(), - trackHelpResourceClicked: vi.fn(), - trackEnterLinear: vi.fn() -})) -vi.mock('@/composables/useRunButtonTelemetry', () => ({ - useRunButtonTelemetry: vi.fn(() => ({ - trackRunButton: mockTelemetry.trackRunButton - })) -})) +const mockTrackHelpResourceClicked = vi.hoisted(() => vi.fn()) vi.mock('@/platform/telemetry', () => ({ - useTelemetry: vi.fn(() => mockTelemetry) -})) - -const mockModelStoreRefresh = vi.hoisted(() => vi.fn()) -vi.mock('@/stores/modelStore', () => ({ - ComfyModelDef: class {}, - useModelStore: vi.fn(() => ({ - refresh: mockModelStoreRefresh + useTelemetry: vi.fn(() => ({ + trackHelpResourceClicked: mockTrackHelpResourceClicked })) })) @@ -177,77 +133,59 @@ vi.mock('@/stores/executionStore', () => ({ useExecutionStore: vi.fn(() => ({})) })) -const mockToastStore = vi.hoisted(() => ({ - add: vi.fn() +vi.mock('@/stores/toastStore', () => ({ + useToastStore: vi.fn(() => ({})) })) + +const mockToastAdd = vi.hoisted(() => vi.fn()) vi.mock('@/platform/updates/common/toastStore', () => ({ - useToastStore: vi.fn(() => mockToastStore) + useToastStore: vi.fn(() => ({ add: mockToastAdd })) +})) + +const mockAssetBrowse = vi.hoisted(() => + vi.fn<(options: { onAssetSelected?: (asset: AssetItem) => void }) => void>() +) +vi.mock('@/platform/assets/composables/useAssetBrowserDialog', () => ({ + useAssetBrowserDialog: vi.fn(() => ({ browse: mockAssetBrowse })) +})) + +const mockStartModelNodeDrag = vi.hoisted(() => vi.fn()) +vi.mock('@/composables/node/startModelNodeDragFromAsset', () => ({ + startModelNodeDragFromAsset: mockStartModelNodeDrag })) const mockChangeTracker = vi.hoisted(() => ({ - captureCanvasState: vi.fn(), - checkState: vi.fn(), - undo: vi.fn(), - redo: vi.fn() + captureCanvasState: vi.fn() })) -interface MockActiveWorkflow { - changeTracker: typeof mockChangeTracker - directory: string - filename: string - isPersisted: boolean - suffix: string -} const mockWorkflowStore = vi.hoisted(() => ({ activeWorkflow: { - changeTracker: mockChangeTracker, - directory: '/workflows', - filename: 'old.json', - isPersisted: true, - suffix: 'json' - } as MockActiveWorkflow | null + changeTracker: mockChangeTracker + } })) vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ useWorkflowStore: vi.fn(() => mockWorkflowStore) })) -const mockSubgraphStore = vi.hoisted(() => ({ - publishSubgraph: vi.fn() -})) vi.mock('@/stores/subgraphStore', () => ({ - useSubgraphStore: vi.fn(() => mockSubgraphStore) + useSubgraphStore: vi.fn(() => ({})) })) -const mockCanvasStore = vi.hoisted(() => ({ - getCanvas: vi.fn(), - canvas: null as unknown, - linearMode: false, - updateSelectedItems: vi.fn() -})) vi.mock('@/renderer/core/canvas/canvasStore', () => ({ - useCanvasStore: vi.fn(() => mockCanvasStore), + useCanvasStore: vi.fn(() => ({ + getCanvas: () => app.canvas, + canvas: app.canvas + })), useTitleEditorStore: vi.fn(() => ({ titleEditorTarget: null })) })) -const mockSubgraphNavigationStore = vi.hoisted(() => ({ - navigationStack: [] as unknown[] -})) -vi.mock('@/stores/subgraphNavigationStore', () => ({ - useSubgraphNavigationStore: vi.fn(() => mockSubgraphNavigationStore) -})) - -const mockColorPaletteStore = vi.hoisted(() => ({ - completedActivePalette: { id: 'dark-default', light_theme: false } -})) vi.mock('@/stores/workspace/colorPaletteStore', () => ({ - useColorPaletteStore: vi.fn(() => mockColorPaletteStore) + useColorPaletteStore: vi.fn(() => ({})) })) vi.mock('@/composables/auth/useAuthActions', () => ({ - useAuthActions: vi.fn(() => ({ - logout: vi.fn() - })) + useAuthActions: vi.fn(() => ({})) })) vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({ @@ -257,129 +195,13 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({ })) })) -const mockIsActiveSubscription = vi.hoisted(() => ({ value: true })) -const mockShowSubscriptionDialog = vi.hoisted(() => vi.fn()) vi.mock('@/composables/billing/useBillingContext', () => ({ useBillingContext: vi.fn(() => ({ - isActiveSubscription: mockIsActiveSubscription, - showSubscriptionDialog: mockShowSubscriptionDialog + isActiveSubscription: { value: true }, + showSubscriptionDialog: vi.fn() })) })) -vi.mock('@/composables/auth/useCurrentUser', () => ({ - useCurrentUser: vi.fn(() => ({ - userEmail: ref(''), - resolvedUserInfo: ref(null) - })) -})) - -const mockSelectedItems = vi.hoisted(() => ({ - getSelectedNodes: vi.fn((): unknown[] => []), - toggleSelectedNodesMode: vi.fn() -})) -vi.mock('@/composables/canvas/useSelectedLiteGraphItems', () => ({ - useSelectedLiteGraphItems: vi.fn(() => mockSelectedItems) -})) - -vi.mock('@/composables/graph/useSubgraphOperations', () => ({ - useSubgraphOperations: vi.fn(() => ({ - unpackSubgraph: vi.fn() - })) -})) - -vi.mock('@/composables/useExternalLink', () => ({ - useExternalLink: vi.fn(() => ({ - staticUrls: { - githubIssues: 'https://github.com/issues', - discord: 'https://discord.gg/test', - forum: 'https://forum.test.com' - }, - buildDocsUrl: vi.fn(() => 'https://docs.test.com') - })) -})) - -vi.mock('@/composables/useModelSelectorDialog', () => ({ - useModelSelectorDialog: vi.fn(() => ({ - show: vi.fn() - })) -})) - -vi.mock('@/composables/useWorkflowTemplateSelectorDialog', () => ({ - useWorkflowTemplateSelectorDialog: vi.fn(() => ({ - show: vi.fn() - })) -})) - -const mockAssetBrowserBrowse = vi.hoisted(() => vi.fn()) -vi.mock('@/platform/assets/composables/useAssetBrowserDialog', () => ({ - useAssetBrowserDialog: vi.fn(() => ({ - browse: mockAssetBrowserBrowse - })) -})) - -vi.mock('@/platform/assets/utils/createModelNodeFromAsset', () => ({ - createModelNodeFromAsset: vi.fn() -})) - -const mockStartModelNodeDragFromAsset = vi.hoisted(() => vi.fn()) -vi.mock('@/composables/node/startModelNodeDragFromAsset', () => ({ - startModelNodeDragFromAsset: mockStartModelNodeDragFromAsset -})) - -const mockManagerState = vi.hoisted(() => ({ - managerUIState: { value: 'enabled' }, - openManager: vi.fn() -})) -vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({ - ManagerUIState: { DISABLED: 'disabled' }, - useManagerState: vi.fn(() => mockManagerState) -})) - -vi.mock('@/platform/support/config', () => ({ - buildSupportUrl: vi.fn(() => 'https://support.test.com') -})) - -const mockFilterOutputNodes = vi.hoisted(() => vi.fn((): LGraphNode[] => [])) -vi.mock('@/utils/nodeFilterUtil', () => ({ - filterOutputNodes: mockFilterOutputNodes -})) - -const mockGetExecutionIdsForSelectedNodes = vi.hoisted(() => - vi.fn((): number[] => []) -) -const mockGetAllNonIoNodesInSubgraph = vi.hoisted(() => - vi.fn((): LGraphNode[] => []) -) -vi.mock('@/utils/graphTraversalUtil', () => ({ - getAllNonIoNodesInSubgraph: mockGetAllNonIoNodesInSubgraph, - getExecutionIdsForSelectedNodes: mockGetExecutionIdsForSelectedNodes, - reduceAllNodes: vi.fn(() => []) -})) - -vi.mock('@/stores/queueStore', () => ({ - useQueueSettingsStore: vi.fn(() => ({ batchCount: 1 })), - useQueueStore: vi.fn(() => ({})), - useQueueUIStore: vi.fn(() => ({})) -})) - -const mockLGraphGroupInstance = vi.hoisted(() => ({ - resizeTo: vi.fn(), - recomputeInsideNodes: vi.fn() -})) -const MockLGraphGroup = vi.hoisted( - () => - function (this: typeof mockLGraphGroupInstance) { - Object.assign(this, mockLGraphGroupInstance) - } -) -vi.mock('@/lib/litegraph/src/litegraph', async () => { - const actual = await vi.importActual('@/lib/litegraph/src/litegraph') - return { - ...actual, - LGraphGroup: MockLGraphGroup - } -}) - describe('useCoreCommands', () => { const createMockNode = (id: number, comfyClass: string): LGraphNode => { const baseNode = createMockLGraphNode({ id }) @@ -393,9 +215,13 @@ describe('useCoreCommands', () => { const createMockSubgraph = () => { const mockNodes = [ + // Mock input node createMockNode(1, 'SubgraphInputNode'), + // Mock output node createMockNode(2, 'SubgraphOutputNode'), + // Mock user node createMockNode(3, 'SomeUserNode'), + // Another mock user node createMockNode(4, 'AnotherUserNode') ] @@ -464,51 +290,31 @@ describe('useCoreCommands', () => { } satisfies ReturnType } - function findCommand(id: string) { - const cmd = useCoreCommands().find((c) => c.id === id) - if (!cmd) throw new Error(`Command '${id}' not found`) - return cmd - } - beforeEach(() => { - vi.resetAllMocks() + vi.clearAllMocks() + // Set up Pinia setActivePinia(createPinia()) + // Reset app state app.canvas.subgraph = undefined - app.canvas.selectedItems = new Set() - app.canvas.state.readOnly = false - app.canvas.state.selectionChanged = false - Object.defineProperty(app.canvas, 'empty', { value: false, writable: true }) - mockCanvasStore.linearMode = false - mockCanvasStore.getCanvas.mockReturnValue(app.canvas) - mockIsActiveSubscription.value = true - mockWorkflowStore.activeWorkflow = { - changeTracker: mockChangeTracker, - directory: '/workflows', - filename: 'old.json', - isPersisted: true, - suffix: 'json' - } - mockColorPaletteStore.completedActivePalette = { - id: 'dark-default', - light_theme: false - } - mockManagerState.managerUIState.value = 'enabled' - mockSubgraphNavigationStore.navigationStack = [] + // Mock settings store vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(false)) - vi.stubGlobal('confirm', vi.fn().mockReturnValue(true)) - vi.stubGlobal( - 'open', - vi.fn().mockReturnValue({ focus: vi.fn(), closed: false }) - ) + // Mock global confirm + global.confirm = vi.fn().mockReturnValue(true) }) describe('ClearWorkflow command', () => { it('should clear main graph when not in subgraph', async () => { - await findCommand('Comfy.ClearWorkflow').function() + const commands = useCoreCommands() + const clearCommand = commands.find( + (cmd) => cmd.id === 'Comfy.ClearWorkflow' + )! + + // Execute the command + await clearCommand.function() expect(app.clean).toHaveBeenCalled() expect(app.rootGraph.clear).toHaveBeenCalled() @@ -516,33 +322,46 @@ describe('useCoreCommands', () => { }) it('should preserve input/output nodes when clearing subgraph', async () => { + // Set up subgraph context app.canvas.subgraph = mockSubgraph - mockGetAllNonIoNodesInSubgraph.mockReturnValue([ - mockSubgraph.nodes[2], - mockSubgraph.nodes[3] - ]) - await findCommand('Comfy.ClearWorkflow').function() + const commands = useCoreCommands() + const clearCommand = commands.find( + (cmd) => cmd.id === 'Comfy.ClearWorkflow' + )! + + // Execute the command + await clearCommand.function() expect(app.clean).toHaveBeenCalled() expect(app.rootGraph.clear).not.toHaveBeenCalled() + // Should only remove user nodes, not input/output nodes const subgraph = app.canvas.subgraph! expect(subgraph.remove).toHaveBeenCalledTimes(2) - expect(subgraph.remove).toHaveBeenCalledWith(subgraph.nodes[2]) - expect(subgraph.remove).toHaveBeenCalledWith(subgraph.nodes[3]) - expect(subgraph.remove).not.toHaveBeenCalledWith(subgraph.nodes[0]) - expect(subgraph.remove).not.toHaveBeenCalledWith(subgraph.nodes[1]) + expect(subgraph.remove).toHaveBeenCalledWith(subgraph.nodes[2]) // user1 + expect(subgraph.remove).toHaveBeenCalledWith(subgraph.nodes[3]) // user2 + expect(subgraph.remove).not.toHaveBeenCalledWith(subgraph.nodes[0]) // input1 + expect(subgraph.remove).not.toHaveBeenCalledWith(subgraph.nodes[1]) // output1 expect(api.dispatchCustomEvent).toHaveBeenCalledWith('graphCleared') }) it('should respect confirmation setting', async () => { + // Mock confirmation required vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(true)) - vi.stubGlobal('confirm', vi.fn().mockReturnValue(false)) - await findCommand('Comfy.ClearWorkflow').function() + global.confirm = vi.fn().mockReturnValue(false) // User cancels + const commands = useCoreCommands() + const clearCommand = commands.find( + (cmd) => cmd.id === 'Comfy.ClearWorkflow' + )! + + // Execute the command + await clearCommand.function() + + // Should not clear anything when user cancels expect(app.clean).not.toHaveBeenCalled() expect(app.rootGraph.clear).not.toHaveBeenCalled() expect(api.dispatchCustomEvent).not.toHaveBeenCalled() @@ -550,6 +369,17 @@ describe('useCoreCommands', () => { }) describe('Canvas clipboard commands', () => { + function findCommand(id: string) { + return useCoreCommands().find((cmd) => cmd.id === id)! + } + + beforeEach(() => { + app.canvas.selectedItems = new Set() + vi.mocked(app.canvas.copyToClipboard).mockClear() + vi.mocked(app.canvas.pasteFromClipboard).mockClear() + vi.mocked(app.canvas.selectItems).mockClear() + }) + it('should copy selected items when selection exists', async () => { app.canvas.selectedItems = new Set([ {} @@ -572,734 +402,14 @@ describe('useCoreCommands', () => { expect(app.canvas.pasteFromClipboard).toHaveBeenCalledWith() }) - it('should paste with connect option', async () => { - await findCommand('Comfy.Canvas.PasteFromClipboardWithConnect').function() - - expect(app.canvas.pasteFromClipboard).toHaveBeenCalledWith({ - connectInputs: true - }) - }) - it('should select all items', async () => { await findCommand('Comfy.Canvas.SelectAll').function() + // No arguments means "select all items on canvas" expect(app.canvas.selectItems).toHaveBeenCalledWith() }) }) - describe('Undo/Redo commands', () => { - it('Undo should call changeTracker.undo', async () => { - await findCommand('Comfy.Undo').function() - - expect(mockChangeTracker.undo).toHaveBeenCalled() - }) - - it('Redo should call changeTracker.redo', async () => { - await findCommand('Comfy.Redo').function() - - expect(mockChangeTracker.redo).toHaveBeenCalled() - }) - }) - - describe('Canvas lock commands', () => { - it('ToggleLock should toggle readOnly state', async () => { - app.canvas.state.readOnly = false - - await findCommand('Comfy.Canvas.ToggleLock').function() - expect(app.canvas.state.readOnly).toBe(true) - - await findCommand('Comfy.Canvas.ToggleLock').function() - expect(app.canvas.state.readOnly).toBe(false) - }) - - it('Lock should set readOnly to true', async () => { - await findCommand('Comfy.Canvas.Lock').function() - expect(app.canvas.state.readOnly).toBe(true) - }) - - it('Unlock should set readOnly to false', async () => { - app.canvas.state.readOnly = true - await findCommand('Comfy.Canvas.Unlock').function() - expect(app.canvas.state.readOnly).toBe(false) - }) - }) - - describe('Canvas delete command', () => { - it('should delete selected items when selection exists', async () => { - app.canvas.selectedItems = new Set([ - {} - ]) as typeof app.canvas.selectedItems - - await findCommand('Comfy.Canvas.DeleteSelectedItems').function() - - expect(app.canvas.deleteSelected).toHaveBeenCalled() - expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true) - }) - - it('should dispatch no-items-selected event when nothing selected', async () => { - app.canvas.selectedItems = new Set() - - await findCommand('Comfy.Canvas.DeleteSelectedItems').function() - - expect(app.canvas.canvas.dispatchEvent).toHaveBeenCalledWith( - expect.objectContaining({ type: 'litegraph:no-items-selected' }) - ) - expect(app.canvas.deleteSelected).not.toHaveBeenCalled() - }) - }) - - describe('ToggleLinkVisibility command', () => { - it('should hide links when currently visible', async () => { - const mockStore = createMockSettingStore(false) - mockStore.get = vi.fn().mockReturnValue(LiteGraph.SPLINE_LINK) - vi.mocked(useSettingStore).mockReturnValue(mockStore) - - await findCommand('Comfy.Canvas.ToggleLinkVisibility').function() - - expect(mockStore.set).toHaveBeenCalledWith( - 'Comfy.LinkRenderMode', - LiteGraph.HIDDEN_LINK - ) - }) - - it('should restore links when currently hidden', async () => { - const mockStore = createMockSettingStore(false) - mockStore.get = vi.fn().mockReturnValue(LiteGraph.HIDDEN_LINK) - vi.mocked(useSettingStore).mockReturnValue(mockStore) - - await findCommand('Comfy.Canvas.ToggleLinkVisibility').function() - - const lastSetCall = vi.mocked(mockStore.set).mock.calls.at(-1) - expect(lastSetCall?.[0]).toBe('Comfy.LinkRenderMode') - expect(lastSetCall?.[1]).not.toBe(LiteGraph.HIDDEN_LINK) - }) - }) - - describe('ToggleMinimap command', () => { - it('should toggle minimap visibility setting', async () => { - const mockStore = createMockSettingStore(false) - mockStore.get = vi.fn().mockReturnValue(false) - vi.mocked(useSettingStore).mockReturnValue(mockStore) - - await findCommand('Comfy.Canvas.ToggleMinimap').function() - - expect(mockStore.set).toHaveBeenCalledWith('Comfy.Minimap.Visible', true) - }) - }) - - describe('QueuePrompt commands', () => { - it('should show subscription dialog when not subscribed', async () => { - mockIsActiveSubscription.value = false - - await findCommand('Comfy.QueuePrompt').function() - - expect(mockShowSubscriptionDialog).toHaveBeenCalled() - expect(app.queuePrompt).not.toHaveBeenCalled() - - mockIsActiveSubscription.value = true - }) - - it('should queue prompt when subscribed', async () => { - await findCommand('Comfy.QueuePrompt').function() - - expect(app.queuePrompt).toHaveBeenCalledWith(0, 1) - expect(mockTelemetry.trackRunButton).toHaveBeenCalled() - expect(mockTelemetry.trackWorkflowExecution).toHaveBeenCalled() - }) - - it('should queue prompt at front', async () => { - await findCommand('Comfy.QueuePromptFront').function() - - expect(app.queuePrompt).toHaveBeenCalledWith(-1, 1) - }) - - it('should show subscription dialog instead of queuing at front when not subscribed', async () => { - mockIsActiveSubscription.value = false - - await findCommand('Comfy.QueuePromptFront').function() - - expect(mockShowSubscriptionDialog).toHaveBeenCalled() - expect(app.queuePrompt).not.toHaveBeenCalled() - }) - }) - - describe('QueueSelectedOutputNodes command', () => { - it('should show subscription dialog before checking selected output nodes', async () => { - mockIsActiveSubscription.value = false - - await findCommand('Comfy.QueueSelectedOutputNodes').function() - - expect(mockShowSubscriptionDialog).toHaveBeenCalled() - expect(mockFilterOutputNodes).not.toHaveBeenCalled() - }) - - it('should show error toast when no output nodes selected', async () => { - await findCommand('Comfy.QueueSelectedOutputNodes').function() - - expect(mockToastStore.add).toHaveBeenCalledWith( - expect.objectContaining({ severity: 'error' }) - ) - expect(app.queuePrompt).not.toHaveBeenCalled() - }) - - it('should queue selected output nodes when valid selection exists', async () => { - const mockNode = createMockLGraphNode({ id: 1 }) - mockSelectedItems.getSelectedNodes.mockReturnValue([mockNode]) - mockFilterOutputNodes.mockReturnValue([mockNode]) - mockGetExecutionIdsForSelectedNodes.mockReturnValue([1]) - - await findCommand('Comfy.QueueSelectedOutputNodes').function() - - expect(app.queuePrompt).toHaveBeenCalledWith(0, 1, [1]) - expect(mockTelemetry.trackWorkflowExecution).toHaveBeenCalled() - }) - - it('should show error toast when selected output nodes cannot resolve execution ids', async () => { - const mockNode = createMockLGraphNode({ id: 1 }) - mockSelectedItems.getSelectedNodes.mockReturnValue([mockNode]) - mockFilterOutputNodes.mockReturnValue([mockNode]) - mockGetExecutionIdsForSelectedNodes.mockReturnValue([]) - - await findCommand('Comfy.QueueSelectedOutputNodes').function() - - expect(mockToastStore.add).toHaveBeenCalledWith( - expect.objectContaining({ - severity: 'error' - }) - ) - expect(app.queuePrompt).not.toHaveBeenCalled() - }) - }) - - describe('MoveSelectedNodes commands', () => { - function setupMoveTest() { - const mockNode = createMockLGraphNode({ id: 1 }) - mockNode.pos = [100, 200] as [number, number] - mockSelectedItems.getSelectedNodes.mockReturnValue([mockNode]) - - const mockStore = createMockSettingStore(false) - mockStore.get = vi.fn().mockReturnValue(10) - vi.mocked(useSettingStore).mockReturnValue(mockStore) - - return mockNode - } - - it('should move nodes up by grid size', async () => { - const mockNode = setupMoveTest() - - await findCommand('Comfy.Canvas.MoveSelectedNodes.Up').function() - - expect(mockNode.pos).toEqual([100, 190]) - expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true) - }) - - it('should move nodes down by grid size', async () => { - const mockNode = setupMoveTest() - - await findCommand('Comfy.Canvas.MoveSelectedNodes.Down').function() - - expect(mockNode.pos).toEqual([100, 210]) - }) - - it('should move nodes left by grid size', async () => { - const mockNode = setupMoveTest() - - await findCommand('Comfy.Canvas.MoveSelectedNodes.Left').function() - - expect(mockNode.pos).toEqual([90, 200]) - }) - - it('should move nodes right by grid size', async () => { - const mockNode = setupMoveTest() - - await findCommand('Comfy.Canvas.MoveSelectedNodes.Right').function() - - expect(mockNode.pos).toEqual([110, 200]) - }) - - it('should not move when no nodes selected', async () => { - mockSelectedItems.getSelectedNodes.mockReturnValue([]) - - await findCommand('Comfy.Canvas.MoveSelectedNodes.Up').function() - - expect(app.canvas.setDirty).not.toHaveBeenCalled() - }) - }) - - describe('ToggleLinear command', () => { - it('should toggle linear mode and track telemetry when entering', async () => { - mockCanvasStore.linearMode = false - - await findCommand('Comfy.ToggleLinear').function() - - expect(mockCanvasStore.linearMode).toBe(true) - expect(mockTelemetry.trackEnterLinear).toHaveBeenCalledWith({ - source: 'keybind' - }) - }) - - it('should use provided source metadata', async () => { - mockCanvasStore.linearMode = false - - await findCommand('Comfy.ToggleLinear').function({ - source: 'menu' - }) - - expect(mockTelemetry.trackEnterLinear).toHaveBeenCalledWith({ - source: 'menu' - }) - }) - - it('does not track when leaving linear mode', async () => { - mockCanvasStore.linearMode = true - - await findCommand('Comfy.ToggleLinear').function() - - expect(mockCanvasStore.linearMode).toBe(false) - expect(mockTelemetry.trackEnterLinear).not.toHaveBeenCalled() - }) - }) - - describe('ExitSubgraph command', () => { - it('does nothing when the canvas has no graph', async () => { - const setGraph = vi.fn() - mockCanvasStore.getCanvas.mockReturnValue({ - graph: null, - setGraph - }) - - await findCommand('Comfy.Graph.ExitSubgraph').function() - - expect(setGraph).not.toHaveBeenCalled() - }) - - it('falls back to the root graph without navigation history', async () => { - const rootGraph = {} - const setGraph = vi.fn() - mockCanvasStore.getCanvas.mockReturnValue({ - graph: { rootGraph }, - setGraph - }) - - await findCommand('Comfy.Graph.ExitSubgraph').function() - - expect(setGraph).toHaveBeenCalledWith(rootGraph) - }) - }) - - describe('ToggleQPOV2 command', () => { - it('should toggle queue panel v2 setting', async () => { - const mockStore = createMockSettingStore(false) - mockStore.get = vi.fn().mockReturnValue(false) - vi.mocked(useSettingStore).mockReturnValue(mockStore) - - await findCommand('Comfy.ToggleQPOV2').function() - - expect(mockStore.set).toHaveBeenCalledWith('Comfy.Queue.QPOV2', true) - }) - }) - - describe('Memory commands', () => { - it('UnloadModels should show error when setting is disabled', async () => { - const mockStore = createMockSettingStore(false) - mockStore.get = vi.fn().mockReturnValue(false) - vi.mocked(useSettingStore).mockReturnValue(mockStore) - - await findCommand('Comfy.Memory.UnloadModels').function() - - expect(mockToastStore.add).toHaveBeenCalledWith( - expect.objectContaining({ severity: 'error' }) - ) - expect(api.freeMemory).not.toHaveBeenCalled() - }) - - it('UnloadModels should call api.freeMemory when setting is enabled', async () => { - const mockStore = createMockSettingStore(false) - mockStore.get = vi.fn().mockReturnValue(true) - vi.mocked(useSettingStore).mockReturnValue(mockStore) - - await findCommand('Comfy.Memory.UnloadModels').function() - - expect(api.freeMemory).toHaveBeenCalledWith({ - freeExecutionCache: false - }) - }) - - it('UnloadModelsAndExecutionCache should call api.freeMemory with cache flag', async () => { - const mockStore = createMockSettingStore(false) - mockStore.get = vi.fn().mockReturnValue(true) - vi.mocked(useSettingStore).mockReturnValue(mockStore) - - await findCommand('Comfy.Memory.UnloadModelsAndExecutionCache').function() - - expect(api.freeMemory).toHaveBeenCalledWith({ - freeExecutionCache: true - }) - }) - }) - - describe('Asset browser commands', () => { - it('does not enable the asset API when confirmation is declined', async () => { - const mockStore = createMockSettingStore(false) - mockStore.get = vi.fn().mockReturnValue(false) - mockDialogService.confirm.mockResolvedValue(false) - vi.mocked(useSettingStore).mockReturnValue(mockStore) - - await findCommand('Comfy.BrowseModelAssets').function() - - expect(mockStore.set).not.toHaveBeenCalled() - expect(mockAssetBrowserBrowse).not.toHaveBeenCalled() - }) - - it('enables asset API before browsing and reports node creation failures', async () => { - const mockStore = createMockSettingStore(false) - mockStore.get = vi.fn().mockReturnValue(false) - mockDialogService.confirm.mockResolvedValue(true) - mockStartModelNodeDragFromAsset.mockReturnValue(new Error('bad asset')) - vi.mocked(useSettingStore).mockReturnValue(mockStore) - vi.spyOn(console, 'error').mockImplementation(() => undefined) - - await findCommand('Comfy.BrowseModelAssets').function() - const browseOptions = mockAssetBrowserBrowse.mock.calls[0][0] - browseOptions.onAssetSelected({}) - - expect(mockStore.set).toHaveBeenCalledWith( - 'Comfy.Assets.UseAssetAPI', - true - ) - expect(mockWorkflowService.reloadCurrentWorkflow).toHaveBeenCalled() - expect(mockStartModelNodeDragFromAsset).toHaveBeenCalledWith( - {}, - 'asset_browser' - ) - expect(mockToastStore.add).toHaveBeenCalledWith( - expect.objectContaining({ severity: 'error' }) - ) - }) - - it('toggles the asset API setting and reloads the workflow', async () => { - const mockStore = createMockSettingStore(false) - mockStore.get = vi.fn().mockReturnValue(true) - vi.mocked(useSettingStore).mockReturnValue(mockStore) - const label = findCommand('Comfy.ToggleAssetAPI').label - - if (typeof label !== 'function') - throw new Error('Expected label function') - expect(label()).toContain('Disable') - await findCommand('Comfy.ToggleAssetAPI').function() - - expect(mockStore.set).toHaveBeenCalledWith( - 'Comfy.Assets.UseAssetAPI', - false - ) - expect(mockWorkflowService.reloadCurrentWorkflow).toHaveBeenCalled() - }) - }) - - describe('FitView command', () => { - it('should show error toast when canvas is empty', async () => { - Object.defineProperty(app.canvas, 'empty', { - value: true, - writable: true - }) - - await findCommand('Comfy.Canvas.FitView').function() - - expect(mockToastStore.add).toHaveBeenCalledWith( - expect.objectContaining({ severity: 'error' }) - ) - expect(app.canvas.fitViewToSelectionAnimated).not.toHaveBeenCalled() - }) - - it('should fit view when canvas has content', async () => { - Object.defineProperty(app.canvas, 'empty', { - value: false, - writable: true - }) - - await findCommand('Comfy.Canvas.FitView').function() - - expect(app.canvas.fitViewToSelectionAnimated).toHaveBeenCalled() - }) - }) - - describe('Interrupt command', () => { - it('should call api.interrupt and show toast', async () => { - await findCommand('Comfy.Interrupt').function() - - expect(api.interrupt).toHaveBeenCalled() - expect(mockToastStore.add).toHaveBeenCalledWith( - expect.objectContaining({ severity: 'info' }) - ) - }) - }) - - describe('OpenWorkflow command', () => { - it('should call app.ui.loadFile', async () => { - await findCommand('Comfy.OpenWorkflow').function() - - expect(app.ui.loadFile).toHaveBeenCalled() - }) - }) - - describe('ToggleTheme command', () => { - it('should switch from dark to light theme', async () => { - const mockStore = createMockSettingStore(false) - vi.mocked(useSettingStore).mockReturnValue(mockStore) - - await findCommand('Comfy.ToggleTheme').function() - - expect(mockStore.set).toHaveBeenCalledWith( - 'Comfy.ColorPalette', - expect.any(String) - ) - }) - - it('should switch from light to the previous dark theme', async () => { - const mockStore = createMockSettingStore(false) - vi.mocked(useSettingStore).mockReturnValue(mockStore) - mockColorPaletteStore.completedActivePalette = { - id: 'light-default', - light_theme: true - } - - await findCommand('Comfy.ToggleTheme').function() - - expect(mockStore.set).toHaveBeenCalledWith( - 'Comfy.ColorPalette', - expect.any(String) - ) - }) - }) - - describe('ToggleSelectedNodes commands', () => { - it('Mute should toggle selected nodes mode and mark dirty', async () => { - await findCommand('Comfy.Canvas.ToggleSelectedNodes.Mute').function() - - expect(mockSelectedItems.toggleSelectedNodesMode).toHaveBeenCalled() - expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true) - }) - - it('Bypass should toggle selected nodes mode and mark dirty', async () => { - await findCommand('Comfy.Canvas.ToggleSelectedNodes.Bypass').function() - - expect(mockSelectedItems.toggleSelectedNodesMode).toHaveBeenCalled() - expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true) - }) - - it('Pin should toggle pin state on each selected node', async () => { - const mockNode = createMockLGraphNode({ id: 1 }) - Object.defineProperty(mockNode, 'pinned', { - value: false, - writable: true - }) - mockNode.pin = vi.fn() - mockSelectedItems.getSelectedNodes.mockReturnValue([mockNode]) - - await findCommand('Comfy.Canvas.ToggleSelectedNodes.Pin').function() - - expect(mockNode.pin).toHaveBeenCalledWith(true) - expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true) - }) - - it('ToggleSelected.Pin pins selected nodes and groups only', async () => { - const mockNode = new LGraphNode('MockNode') - const mockGroup = new LGraphGroup() - Object.defineProperty(mockNode, 'pinned', { - value: false - }) - mockNode.pin = vi.fn() - Object.assign(mockGroup, { - pinned: true, - pin: vi.fn() - }) - app.canvas.selectedItems = new Set([ - mockNode, - mockGroup, - {} - ]) as typeof app.canvas.selectedItems - - await findCommand('Comfy.Canvas.ToggleSelected.Pin').function() - - expect(mockNode.pin).toHaveBeenCalledWith(true) - expect(mockGroup.pin).toHaveBeenCalledWith(false) - expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true) - }) - - it('Collapse should collapse each selected node', async () => { - const mockNode = createMockLGraphNode({ id: 1 }) - mockNode.collapse = vi.fn() - mockSelectedItems.getSelectedNodes.mockReturnValue([mockNode]) - - await findCommand('Comfy.Canvas.ToggleSelectedNodes.Collapse').function() - - expect(mockNode.collapse).toHaveBeenCalled() - expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true) - }) - - it('Resize should compute and set optimal size', async () => { - const mockNode = createMockLGraphNode({ id: 1 }) - mockNode.computeSize = vi.fn().mockReturnValue([200, 100]) - mockNode.setSize = vi.fn() - mockSelectedItems.getSelectedNodes.mockReturnValue([mockNode]) - - await findCommand('Comfy.Canvas.Resize').function() - - expect(mockNode.computeSize).toHaveBeenCalled() - expect(mockNode.setSize).toHaveBeenCalledWith([200, 100]) - expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true) - }) - }) - - describe('Help commands', () => { - it('OpenComfyUIIssues should open GitHub issues and track telemetry', async () => { - await findCommand('Comfy.Help.OpenComfyUIIssues').function() - - expect(mockTelemetry.trackHelpResourceClicked).toHaveBeenCalledWith({ - resource_type: 'github', - is_external: true, - source: 'menu' - }) - expect(window.open).toHaveBeenCalledWith( - 'https://github.com/issues', - '_blank' - ) - }) - - it('OpenComfyUIDocs should open docs and track telemetry', async () => { - await findCommand('Comfy.Help.OpenComfyUIDocs').function() - - expect(mockTelemetry.trackHelpResourceClicked).toHaveBeenCalledWith({ - resource_type: 'docs', - is_external: true, - source: 'menu' - }) - expect(window.open).toHaveBeenCalledWith( - 'https://docs.test.com', - '_blank' - ) - }) - - it('OpenComfyOrgDiscord should open Discord and track telemetry', async () => { - await findCommand('Comfy.Help.OpenComfyOrgDiscord').function() - - expect(mockTelemetry.trackHelpResourceClicked).toHaveBeenCalledWith({ - resource_type: 'discord', - is_external: true, - source: 'menu' - }) - expect(window.open).toHaveBeenCalledWith( - 'https://discord.gg/test', - '_blank' - ) - }) - - it('OpenComfyUIForum should open forum and track telemetry', async () => { - await findCommand('Comfy.Help.OpenComfyUIForum').function() - - expect(mockTelemetry.trackHelpResourceClicked).toHaveBeenCalledWith({ - resource_type: 'help_feedback', - is_external: true, - source: 'menu' - }) - expect(window.open).toHaveBeenCalledWith( - 'https://forum.test.com', - '_blank' - ) - }) - }) - - describe('GroupSelectedNodes command', () => { - it('should show error toast when nothing selected', async () => { - app.canvas.selectedItems = new Set() - - await findCommand('Comfy.Graph.GroupSelectedNodes').function() - - expect(mockToastStore.add).toHaveBeenCalledWith( - expect.objectContaining({ severity: 'error' }) - ) - }) - - it('should create group when items are selected', async () => { - const mockNode = createMockLGraphNode({ id: 1 }) - app.canvas.selectedItems = new Set([ - mockNode - ]) as typeof app.canvas.selectedItems - - await findCommand('Comfy.Graph.GroupSelectedNodes').function() - - expect(mockLGraphGroupInstance.resizeTo).toHaveBeenCalled() - expect(app.canvas.graph!.add).toHaveBeenCalled() - }) - }) - - describe('FitGroupToContents command', () => { - it('resizes selected groups and ignores non-groups', async () => { - const group = new LGraphGroup() - Object.assign(group, { - children: new Set([createMockLGraphNode({ id: 1 })]) - }) - app.canvas.selectedItems = new Set([ - createMockLGraphNode({ id: 2 }), - group - ]) as typeof app.canvas.selectedItems - - await findCommand('Comfy.Graph.FitGroupToContents').function() - - expect(mockLGraphGroupInstance.recomputeInsideNodes).toHaveBeenCalled() - expect(mockLGraphGroupInstance.resizeTo).toHaveBeenCalled() - expect(app.canvas.setDirty).toHaveBeenCalledWith(false, true) - }) - }) - - describe('ConvertToSubgraph command', () => { - it('throws when the active canvas has no graph', async () => { - mockCanvasStore.getCanvas.mockReturnValue({ - selectedItems: new Set(), - graph: null, - subgraph: null - }) - - await expect(async () => { - await findCommand('Comfy.Graph.ConvertToSubgraph').function() - }).rejects.toThrow('Canvas has no graph or subgraph set.') - }) - - it('should show error toast when conversion fails', async () => { - app.canvas.graph!.convertToSubgraph = vi.fn().mockReturnValue(null) - - await findCommand('Comfy.Graph.ConvertToSubgraph').function() - - expect(mockToastStore.add).toHaveBeenCalledWith( - expect.objectContaining({ severity: 'error' }) - ) - }) - - it('should select the new subgraph node on success', async () => { - const mockNode = createMockLGraphNode({ id: 1 }) - app.canvas.graph!.convertToSubgraph = vi - .fn() - .mockReturnValue({ node: mockNode }) - - await findCommand('Comfy.Graph.ConvertToSubgraph').function() - - expect(app.canvas.select).toHaveBeenCalledWith(mockNode) - expect(mockCanvasStore.updateSelectedItems).toHaveBeenCalled() - }) - }) - - describe('ContactSupport command', () => { - it('should open support URL in new window', async () => { - await findCommand('Comfy.ContactSupport').function() - - expect(window.open).toHaveBeenCalledWith( - 'https://support.test.com', - '_blank', - 'noopener,noreferrer' - ) - }) - }) - describe('Subgraph metadata commands', () => { beforeEach(() => { mockSubgraph.extra = {} @@ -1350,18 +460,6 @@ describe('useCoreCommands', () => { expect(mockSubgraph.extra.BlueprintDescription).toBeUndefined() expect(mockChangeTracker.captureCanvasState).not.toHaveBeenCalled() }) - - it('coerces metadata descriptions and removes blank descriptions', async () => { - app.canvas.subgraph = mockSubgraph - const setDescCommand = findCommand('Comfy.Subgraph.SetDescription') - - await setDescCommand.function({ description: 123 }) - expect(mockSubgraph.extra.BlueprintDescription).toBe('123') - - await setDescCommand.function({ description: ' ' }) - expect(mockSubgraph.extra.BlueprintDescription).toBeUndefined() - expect(mockDialogService.prompt).not.toHaveBeenCalled() - }) }) describe('SetSearchAliases command', () => { @@ -1443,58 +541,22 @@ describe('useCoreCommands', () => { expect(mockSubgraph.extra.BlueprintSearchAliases).toBeUndefined() expect(mockChangeTracker.captureCanvasState).not.toHaveBeenCalled() }) - - it('accepts alias metadata arrays without prompting', async () => { - app.canvas.subgraph = mockSubgraph - - await findCommand('Comfy.Subgraph.SetSearchAliases').function({ - aliases: [' portrait ', 7, '', 'landscape'] - }) - - expect(mockDialogService.prompt).not.toHaveBeenCalled() - expect(mockSubgraph.extra.BlueprintSearchAliases).toEqual([ - 'portrait', - '7', - 'landscape' - ]) - }) - }) - }) - - describe('Manager commands', () => { - it('shows an error when manager update checks are disabled', async () => { - mockManagerState.managerUIState.value = 'disabled' - - await findCommand('Comfy.Manager.ShowUpdateAvailablePacks').function() - - expect(mockToastStore.add).toHaveBeenCalledWith( - expect.objectContaining({ severity: 'error' }) - ) - expect(mockManagerState.openManager).not.toHaveBeenCalled() - }) - - it('opens the update-available manager tab when enabled', async () => { - await findCommand('Comfy.Manager.ShowUpdateAvailablePacks').function() - - expect(mockManagerState.openManager).toHaveBeenCalledWith( - expect.objectContaining({ - initialTab: expect.any(String), - showToastOnLegacyError: false - }) - ) }) }) describe('Canvas view commands', () => { + const findCmd = (id: string) => + useCoreCommands().find((cmd) => cmd.id === id)! + it('Comfy.Canvas.ResetView delegates to litegraphService.resetView', async () => { - await findCommand('Comfy.Canvas.ResetView').function() + await findCmd('Comfy.Canvas.ResetView').function() expect(mockResetView).toHaveBeenCalled() }) it('Comfy.Canvas.ZoomIn scales the canvas up by 1.1× and marks it dirty', async () => { app.canvas.ds.scale = 1 - await findCommand('Comfy.Canvas.ZoomIn').function() + await findCmd('Comfy.Canvas.ZoomIn').function() expect(app.canvas.ds.changeScale).toHaveBeenCalledWith( 1.1, @@ -1505,7 +567,7 @@ describe('useCoreCommands', () => { it('Comfy.Canvas.ZoomOut scales the canvas down by 1/1.1× and marks it dirty', async () => { app.canvas.ds.scale = 1 - await findCommand('Comfy.Canvas.ZoomOut').function() + await findCmd('Comfy.Canvas.ZoomOut').function() expect(app.canvas.ds.changeScale).toHaveBeenCalledWith( 1 / 1.1, @@ -1513,163 +575,109 @@ describe('useCoreCommands', () => { ) expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true) }) - - it('Zoom commands handle a missing backing canvas element', async () => { - const ds = app.canvas.ds as { element?: HTMLCanvasElement } - ds.element = undefined - - await findCommand('Comfy.Canvas.ZoomIn').function() - await findCommand('Comfy.Canvas.ZoomOut').function() - - expect(app.canvas.ds.changeScale).toHaveBeenNthCalledWith( - 1, - 1.1, - undefined - ) - expect(app.canvas.ds.changeScale).toHaveBeenNthCalledWith( - 2, - 1 / 1.1, - undefined - ) - }) }) describe('Workflow lifecycle commands', () => { + const findCmd = (id: string) => + useCoreCommands().find((cmd) => cmd.id === id)! + it('Comfy.OpenClipspace delegates to app.openClipspace', async () => { - await findCommand('Comfy.OpenClipspace').function() + await findCmd('Comfy.OpenClipspace').function() expect(app.openClipspace).toHaveBeenCalled() }) - it('Comfy.RefreshNodeDefinitions awaits app.refreshComboInNodes', async () => { - await findCommand('Comfy.RefreshNodeDefinitions').function() + it('Comfy.RefreshNodeDefinitions refreshes combos and the model library', async () => { + await findCmd('Comfy.RefreshNodeDefinitions').function() expect(app.refreshComboInNodes).toHaveBeenCalled() - }) - - it('creates blank and default workflows with telemetry', async () => { - app.rootGraph._nodes = [createMockLGraphNode({ id: 1 })] - - await findCommand('Comfy.NewBlankWorkflow').function() - await findCommand('Comfy.LoadDefaultWorkflow').function() - - expect(mockWorkflowService.loadBlankWorkflow).toHaveBeenCalled() - expect(mockWorkflowService.loadDefaultWorkflow).toHaveBeenCalled() - expect(mockTelemetry.trackWorkflowCreated).toHaveBeenCalledWith({ - workflow_type: 'blank', - previous_workflow_had_nodes: true - }) - expect(mockTelemetry.trackWorkflowCreated).toHaveBeenCalledWith({ - workflow_type: 'default', - previous_workflow_had_nodes: true - }) - }) - - it('saves the active workflow through save and save-as commands', async () => { - await findCommand('Comfy.SaveWorkflow').function() - await findCommand('Comfy.SaveWorkflowAs').function() - - expect(mockWorkflowService.saveWorkflow).toHaveBeenCalledWith( - mockWorkflowStore.activeWorkflow - ) - expect(mockWorkflowService.saveWorkflowAs).toHaveBeenCalledWith( - mockWorkflowStore.activeWorkflow - ) - }) - - it('does nothing when save commands have no active workflow', async () => { - mockWorkflowStore.activeWorkflow = null - - await findCommand('Comfy.SaveWorkflow').function() - await findCommand('Comfy.SaveWorkflowAs').function() - - expect(mockWorkflowService.saveWorkflow).not.toHaveBeenCalled() - expect(mockWorkflowService.saveWorkflowAs).not.toHaveBeenCalled() - }) - - it('renames persisted workflows when the user provides a new name', async () => { - mockDialogService.prompt.mockResolvedValue('renamed') - - await findCommand('Comfy.RenameWorkflow').function() - - expect(mockWorkflowService.renameWorkflow).toHaveBeenCalledWith( - mockWorkflowStore.activeWorkflow, - '/workflows/renamed.json' - ) - }) - - it('does not rename missing, unpersisted, canceled, or unchanged workflows', async () => { - mockWorkflowStore.activeWorkflow = null - await findCommand('Comfy.RenameWorkflow').function() - mockWorkflowStore.activeWorkflow = { - changeTracker: mockChangeTracker, - directory: '/workflows', - filename: 'old.json', - isPersisted: false, - suffix: 'json' - } - await findCommand('Comfy.RenameWorkflow').function() - mockWorkflowStore.activeWorkflow!.isPersisted = true - mockDialogService.prompt.mockResolvedValueOnce(null) - await findCommand('Comfy.RenameWorkflow').function() - mockDialogService.prompt.mockResolvedValueOnce('old.json') - await findCommand('Comfy.RenameWorkflow').function() - - expect(mockWorkflowService.renameWorkflow).not.toHaveBeenCalled() - }) - - it('publishes subgraphs and passes metadata name when supplied', async () => { - await findCommand('Comfy.PublishSubgraph').function({ name: 'Reusable' }) - await findCommand('Comfy.PublishSubgraph').function() - - expect(mockSubgraphStore.publishSubgraph).toHaveBeenCalledWith('Reusable') - expect(mockSubgraphStore.publishSubgraph).toHaveBeenCalledWith(undefined) - }) - - it('exports workflow formats and navigates open workflows', async () => { - await findCommand('Comfy.ExportWorkflow').function() - await findCommand('Comfy.ExportWorkflowAPI').function() - await findCommand('Workspace.NextOpenedWorkflow').function() - await findCommand('Workspace.PreviousOpenedWorkflow').function() - - expect(mockWorkflowService.exportWorkflow).toHaveBeenCalledWith( - 'workflow', - 'workflow' - ) - expect(mockWorkflowService.exportWorkflow).toHaveBeenCalledWith( - 'workflow_api', - 'output' - ) - expect(mockWorkflowService.loadNextOpenedWorkflow).toHaveBeenCalled() - expect(mockWorkflowService.loadPreviousOpenedWorkflow).toHaveBeenCalled() - }) - - it('duplicates and closes active workflows', async () => { - await findCommand('Comfy.DuplicateWorkflow').function() - await findCommand('Workspace.CloseWorkflow').function() - - expect(mockWorkflowService.duplicateWorkflow).toHaveBeenCalledWith( - mockWorkflowStore.activeWorkflow - ) - expect(mockWorkflowService.closeWorkflow).toHaveBeenCalledWith( - mockWorkflowStore.activeWorkflow - ) - }) - - it('does not close when there is no active workflow', async () => { - mockWorkflowStore.activeWorkflow = null - - await findCommand('Workspace.CloseWorkflow').function() - - expect(mockWorkflowService.closeWorkflow).not.toHaveBeenCalled() + expect(mockModelStoreRefresh).toHaveBeenCalled() }) }) - describe('AboutComfyUI command', () => { - it('should open the About dialog', async () => { - await findCommand('Comfy.Help.AboutComfyUI').function() + describe('Help commands', () => { + const findCmd = (id: string) => + useCoreCommands().find((cmd) => cmd.id === id)! + const { staticUrls } = useExternalLink() + let openSpy: ReturnType + + beforeEach(() => { + openSpy = vi + .spyOn(window, 'open') + .mockImplementation(() => null as unknown as Window) + }) + + it('Comfy.Help.OpenComfyUIIssues opens the GitHub issues URL and tracks telemetry', async () => { + await findCmd('Comfy.Help.OpenComfyUIIssues').function() + + expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith( + expect.objectContaining({ + resource_type: 'github', + is_external: true, + source: 'menu' + }) + ) + expect(openSpy).toHaveBeenCalledWith(staticUrls.githubIssues, '_blank') + }) + + it('Comfy.Help.OpenComfyOrgDiscord opens the Discord URL and tracks telemetry', async () => { + await findCmd('Comfy.Help.OpenComfyOrgDiscord').function() + + expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith( + expect.objectContaining({ + resource_type: 'discord' + }) + ) + expect(openSpy).toHaveBeenCalledWith(staticUrls.discord, '_blank') + }) + + it('Comfy.Help.AboutComfyUI opens the About dialog', async () => { + await findCmd('Comfy.Help.AboutComfyUI').function() expect(mockShowAbout).toHaveBeenCalled() }) }) + + describe('BrowseModelAssets command', () => { + const asset = fromPartial({ id: 'asset-1' }) + + async function selectAssetFromBrowser() { + vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(true)) + + const command = useCoreCommands().find( + (cmd) => cmd.id === 'Comfy.BrowseModelAssets' + )! + await command.function() + + const { onAssetSelected } = mockAssetBrowse.mock.calls[0][0] + onAssetSelected?.(asset) + } + + it('starts a model node drag for the selected asset', async () => { + mockStartModelNodeDrag.mockReturnValue(undefined) + + await selectAssetFromBrowser() + + expect(mockStartModelNodeDrag).toHaveBeenCalledWith( + asset, + 'asset_browser' + ) + expect(mockToastAdd).not.toHaveBeenCalled() + }) + + it('shows an error toast when the asset cannot start a drag', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + mockStartModelNodeDrag.mockReturnValue({ + code: 'NO_PROVIDER', + message: 'No node provider registered', + assetId: 'asset-1' + }) + + await selectAssetFromBrowser() + + expect(mockToastAdd).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'error' }) + ) + }) + }) }) diff --git a/src/composables/useWaveAudioPlayer.test.ts b/src/composables/useWaveAudioPlayer.test.ts index e132e8e8f9..aa422c69e2 100644 --- a/src/composables/useWaveAudioPlayer.test.ts +++ b/src/composables/useWaveAudioPlayer.test.ts @@ -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 - currentTime: Ref - duration: Ref - volume: Ref - muted: Ref -} - -const mockMediaControls = vi.hoisted(() => ({ - values: [] as MediaControls[] -})) - vi.mock('@vueuse/core', async (importOriginal) => { const actual = await importOriginal>() 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 = {}) { - 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( - 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({ clientX: 50 })) - expect(controls.currentTime.value).toBe(0) - - player.waveformRef.value = fromAny({ - getBoundingClientRect: () => ({ left: 10, width: 100 }) - }) - - player.handleWaveformClick(fromAny({ clientX: 60 })) - expect(controls.currentTime.value).toBe(50) - expect(player.isPlaying.value).toBe(true) - - player.handleWaveformClick(fromAny({ clientX: -100 })) - expect(controls.currentTime.value).toBe(0) - - player.handleWaveformClick(fromAny({ 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({ - getBoundingClientRect: () => ({ left: 0, width: 100 }) - }) - - player.handleWaveformClick(fromAny({ 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 })