Compare commits

...

18 Commits

Author SHA1 Message Date
Johnpaul
ed1818a09a refactor: simplify useSelectionState by removing unused dependencies and computed properties 2025-09-10 22:34:22 +01:00
Johnpaul
06fcb7642d refactor: better mocking for use selection state 2025-09-10 22:01:33 +01:00
Johnpaul
06aa300807 refactor: update icon class in DeleteButton and streamline useSelectionState composable 2025-09-10 22:00:46 +01:00
Johnpaul
10e149b2d8 Removed Css states from bypassbutton and updates tests 2025-09-10 21:59:56 +01:00
Johnpaul
86466e2cd6 refactor: update icon class in BypassButton and streamline computeSelectionStatesFromNodes function 2025-09-10 02:45:23 +01:00
Johnpaul
ca23170e6d refactor: define types for mockCommandStore and mockSelectionState in BypassButton tests 2025-09-10 02:21:04 +01:00
Johnpaul
3b230ed472 test: add unit tests for useSelectionState composable 2025-09-10 02:20:18 +01:00
Johnpaul
5b6c6a5191 refactor: update BypassButton tests to use mocked selection state and command store 2025-09-10 01:51:02 +01:00
Johnpaul
b91941ecb6 test: refactor unit tests for BypassButton component 2025-09-10 01:42:39 +01:00
Johnpaul
6d1d30f868 refactor: replace storeToRefs with computed for selectedItems in useSelectionState 2025-09-09 22:10:36 +01:00
Johnpaul
11fc941ea8 refactor: use storeToRefs for selectedItems in useSelectionState 2025-09-09 22:03:51 +01:00
Johnpaul
bb45f1d60d refactor: rename byPass function to toggleBypass 2025-09-09 22:03:12 +01:00
Johnpaul
b4aedfd66b refactor: replace mockLGraphNode object with a class implementation for better type safety 2025-09-09 21:40:29 +01:00
Johnpaul
f96afa5daa fix: update tooltip text for BypassButton 2025-09-09 20:51:40 +01:00
Johnpaul
d48c0ace54 feat: add isDeletable computed property and isRemovableItem helper function 2025-09-09 20:51:15 +01:00
Johnpaul
990a36c56c test: add comprehensive tests for useSelectionState composable 2025-09-09 20:50:24 +01:00
Johnpaul
89d5b99a7b refactor: remove export from NodeSelectionState interface for knip use 2025-09-09 19:11:18 +01:00
Johnpaul
6ad894d5af feat: enhance selection toolbox with BypassButton and DeleteButton updates; add refresh functionality 2025-09-09 19:09:11 +01:00
7 changed files with 410 additions and 18 deletions

View File

@@ -0,0 +1,91 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { ref } from 'vue'
import { computed } from 'vue'
import { createI18n } from 'vue-i18n'
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
const commandStore = {
execute: vi.fn()
}
const selectionState = {
hasAnySelection: ref(true)
}
vi.mock('@/stores/commandStore', () => ({
useCommandStore: vi.fn(() => commandStore)
}))
vi.mock('@/composables/graph/useSelectionState', () => ({
useSelectionState: vi.fn(() => selectionState)
}))
describe('BypassButton', () => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
selectionToolbox: {
bypassButton: {
tooltip: 'Fake Bypass text'
}
},
'commands.Comfy_Canvas_ToggleSelectedNodes_Bypass.label':
'Fake Toggle Bypass'
}
}
})
beforeEach(() => {
vi.resetAllMocks()
})
const mountComponent = () => {
return mount(BypassButton, {
global: {
plugins: [i18n, PrimeVue],
directives: { tooltip: Tooltip },
stubs: {
'i-lucide:ban': true
}
}
})
}
test('should render bypass button when items are selected', () => {
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
})
test('should have correct test id', () => {
const wrapper = mountComponent()
const button = wrapper.find('[data-testid="bypass-button"]')
expect(button.exists()).toBe(true)
})
test('should execute bypass command when clicked', async () => {
const wrapper = mountComponent()
await wrapper.find('button').trigger('click')
expect(commandStore.execute).toHaveBeenCalledWith(
'Comfy.Canvas.ToggleSelectedNodes.Bypass'
)
})
test('should show button when hasAnySelection is true', () => {
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.element.style.display).toBe('')
})
test('Button should not show when hasAnySelection is false', async () => {
selectionState.hasAnySelection = computed(() => false)
const wrapper = mountComponent()
await wrapper.vm.$nextTick()
const button = wrapper.find('button')
expect(button.element.style.display).toBe('none')
})
})

View File

@@ -1,6 +1,6 @@
<template>
<Button
v-show="canvasStore.nodeSelected"
v-show="hasAnySelection"
v-tooltip.top="{
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Bypass.label'),
showDelay: 1000
@@ -8,12 +8,11 @@
severity="secondary"
text
data-testid="bypass-button"
@click="
() => commandStore.execute('Comfy.Canvas.ToggleSelectedNodes.Bypass')
"
class="hover:dark-theme:bg-[#262729] hover:bg-[#E7E6E6]"
@click="toggleBypass"
>
<template #icon>
<i-game-icons:detour />
<i-lucide:ban class="size-4" />
</template>
</Button>
</template>
@@ -22,10 +21,15 @@
import Button from 'primevue/button'
import { useI18n } from 'vue-i18n'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const { hasAnySelection } = useSelectionState()
console.log('hasAnySelection', hasAnySelection)
const toggleBypass = async () => {
await commandStore.execute('Comfy.Canvas.ToggleSelectedNodes.Bypass')
}
</script>

View File

@@ -5,26 +5,23 @@
value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
showDelay: 1000
}"
severity="danger"
severity="secondary"
text
icon-class="size-4"
icon="pi pi-trash"
data-testid="delete-button"
@click="() => commandStore.execute('Comfy.Canvas.DeleteSelectedItems')"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isDeletable = computed(() =>
canvasStore.selectedItems.some((x) => x.removable !== false)
)
const { isDeletable } = useSelectionState()
</script>

View File

@@ -1,17 +1,22 @@
<template>
<Button
v-show="isRefreshable"
severity="info"
v-tooltip.top="t('g.refreshNode')"
severity="secondary"
text
icon="pi pi-refresh"
data-testid="refresh-button"
@click="refreshSelected"
/>
>
<i-lucide:refresh-cw class="w-4 h-4" />
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useI18n } from 'vue-i18n'
import { useRefreshableSelection } from '@/composables/useRefreshableSelection'
const { t } = useI18n()
const { isRefreshable, refreshSelected } = useRefreshableSelection()
</script>

View File

@@ -0,0 +1,29 @@
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useCanvasStore } from '@/stores/graphStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
/**
* Centralized computed selection state + shared helper actions to avoid duplication
* between selection toolbox, context menus, and other UI affordances.
*/
export function useSelectionState() {
const canvasStore = useCanvasStore()
const { selectedItems } = storeToRefs(canvasStore)
const selectedNodes = computed(() => {
return selectedItems.value.filter((i) => isLGraphNode(i))
})
const hasAnySelection = computed(() => selectedItems.value.length > 0)
const isDeletable = computed(() =>
selectedItems.value.some((x) => x.removable)
)
return {
selectedItems,
selectedNodes,
hasAnySelection,
isDeletable
}
}

View File

@@ -9,6 +9,7 @@
"import": "Import",
"loadAllFolders": "Load All Folders",
"refresh": "Refresh",
"refreshNode": "Refresh Node",
"terminal": "Terminal",
"logs": "Logs",
"videoFailedToLoad": "Video failed to load",

View File

@@ -0,0 +1,265 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { type Ref, ref } from 'vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
// Test interfaces
interface TestNodeConfig {
type?: string
mode?: LGraphEventMode
flags?: { collapsed?: boolean }
pinned?: boolean
removable?: boolean
}
interface TestNode {
type: string
mode: LGraphEventMode
flags?: { collapsed?: boolean }
pinned?: boolean
removable?: boolean
isSubgraphNode: () => boolean
}
type MockedItem = TestNode | { type: string; isNode: boolean }
// Mock all stores
vi.mock('@/stores/graphStore', () => ({
useCanvasStore: vi.fn()
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: vi.fn()
}))
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: vi.fn()
}))
vi.mock('@/stores/workspace/nodeHelpStore', () => ({
useNodeHelpStore: vi.fn()
}))
vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
useNodeLibrarySidebarTab: vi.fn()
}))
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(),
isImageNode: vi.fn()
}))
vi.mock('@/utils/nodeFilterUtil', () => ({
filterOutputNodes: vi.fn()
}))
const createTestNode = (config: TestNodeConfig = {}): TestNode => {
return {
type: config.type || 'TestNode',
mode: config.mode || LGraphEventMode.ALWAYS,
flags: config.flags,
pinned: config.pinned,
removable: config.removable,
isSubgraphNode: () => false
}
}
// Mock comment/connection objects
const mockComment = { type: 'comment', isNode: false }
const mockConnection = { type: 'connection', isNode: false }
describe('useSelectionState', () => {
// Mock store instances
let mockSelectedItems: Ref<MockedItem[]>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
// Setup mock canvas store with proper ref
mockSelectedItems = ref([])
vi.mocked(useCanvasStore).mockReturnValue({
selectedItems: mockSelectedItems,
// Add minimal required properties for the store
$id: 'canvas',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {} as any
} as any)
// Setup mock node def store
vi.mocked(useNodeDefStore).mockReturnValue({
fromLGraphNode: vi.fn((node: TestNode) => {
if (node?.type === 'TestNode') {
return { nodePath: 'test.TestNode', name: 'TestNode' }
}
return null
}),
// Add minimal required properties for the store
$id: 'nodeDef',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {} as any
} as any)
// Setup mock sidebar tab store
const mockToggleSidebarTab = vi.fn()
vi.mocked(useSidebarTabStore).mockReturnValue({
activeSidebarTabId: null,
toggleSidebarTab: mockToggleSidebarTab,
// Add minimal required properties for the store
$id: 'sidebarTab',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {} as any
} as any)
// Setup mock node help store
const mockOpenHelp = vi.fn()
const mockCloseHelp = vi.fn()
const mockNodeHelpStore = {
isHelpOpen: false,
currentHelpNode: null,
openHelp: mockOpenHelp,
closeHelp: mockCloseHelp,
// Add minimal required properties for the store
$id: 'nodeHelp',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {} as any
}
vi.mocked(useNodeHelpStore).mockReturnValue(mockNodeHelpStore as any)
// Setup mock composables
vi.mocked(useNodeLibrarySidebarTab).mockReturnValue({
id: 'node-library-tab',
title: 'Node Library',
type: 'custom',
render: () => null
} as any)
// Setup mock utility functions
vi.mocked(isLGraphNode).mockImplementation((item: unknown) => {
const typedItem = item as { isNode?: boolean }
return typedItem?.isNode !== false
})
vi.mocked(isImageNode).mockImplementation((node: unknown) => {
const typedNode = node as { type?: string }
return typedNode?.type === 'ImageNode'
})
vi.mocked(filterOutputNodes).mockImplementation(
(nodes: TestNode[]) => nodes.filter((n) => n.type === 'OutputNode') as any
)
})
describe('Selection Detection', () => {
test('should return false when nothing selected', () => {
const { hasAnySelection } = useSelectionState()
expect(hasAnySelection.value).toBe(false)
})
test('should return true when items selected', () => {
const node1 = createTestNode()
const node2 = createTestNode()
mockSelectedItems.value = [node1, node2]
const { hasAnySelection } = useSelectionState()
expect(hasAnySelection.value).toBe(true)
})
})
describe('Node Type Filtering', () => {
test('should pick only LGraphNodes from mixed selections', () => {
const graphNode = createTestNode()
mockSelectedItems.value = [graphNode, mockComment, mockConnection]
const { selectedNodes } = useSelectionState()
expect(selectedNodes.value).toHaveLength(1)
expect(selectedNodes.value[0]).toEqual(graphNode)
})
})
describe('Node State Computation', () => {
test('should detect bypassed nodes', () => {
const bypassedNode = createTestNode({ mode: LGraphEventMode.BYPASS })
mockSelectedItems.value = [bypassedNode]
const { selectedNodes } = useSelectionState()
const isBypassed = selectedNodes.value.some(
(n) => n.mode === LGraphEventMode.BYPASS
)
expect(isBypassed).toBe(true)
})
test('should detect pinned/collapsed states', () => {
const pinnedNode = createTestNode({ pinned: true })
const collapsedNode = createTestNode({ flags: { collapsed: true } })
mockSelectedItems.value = [pinnedNode, collapsedNode]
const { selectedNodes } = useSelectionState()
const isPinned = selectedNodes.value.some((n) => n.pinned === true)
const isCollapsed = selectedNodes.value.some(
(n) => n.flags?.collapsed === true
)
const isBypassed = selectedNodes.value.some(
(n) => n.mode === LGraphEventMode.BYPASS
)
expect(isPinned).toBe(true)
expect(isCollapsed).toBe(true)
expect(isBypassed).toBe(false)
})
test('should provide non-reactive state computation', () => {
const node = createTestNode({ pinned: true })
mockSelectedItems.value = [node]
const { selectedNodes } = useSelectionState()
const isPinned = selectedNodes.value.some((n) => n.pinned === true)
const isCollapsed = selectedNodes.value.some(
(n) => n.flags?.collapsed === true
)
const isBypassed = selectedNodes.value.some(
(n) => n.mode === LGraphEventMode.BYPASS
)
expect(isPinned).toBe(true)
expect(isCollapsed).toBe(false)
expect(isBypassed).toBe(false)
// Test with empty selection using new composable instance
mockSelectedItems.value = []
const { selectedNodes: newSelectedNodes } = useSelectionState()
const newIsPinned = newSelectedNodes.value.some((n) => n.pinned === true)
expect(newIsPinned).toBe(false)
})
})
})