Merge remote-tracking branch 'origin/main' into feat/new-workflow-templates

This commit is contained in:
Johnpaul
2025-09-05 20:22:26 +01:00
614 changed files with 37783 additions and 9017 deletions

View File

@@ -0,0 +1,455 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import Button from 'primevue/button'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import NodeConflictDialogContent from '@/components/dialog/content/manager/NodeConflictDialogContent.vue'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
// Mock getConflictMessage utility
vi.mock('@/utils/conflictMessageUtil', () => ({
getConflictMessage: vi.fn((conflict) => {
return `${conflict.type}: ${conflict.current_value} vs ${conflict.required_value}`
})
}))
// Mock dependencies
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
t: vi.fn((key: string) => {
const translations: Record<string, string> = {
'manager.conflicts.description': 'Some extensions are not compatible',
'manager.conflicts.info': 'Additional info about conflicts',
'manager.conflicts.conflicts': 'Conflicts',
'manager.conflicts.extensionAtRisk': 'Extensions at Risk',
'manager.conflicts.importFailedExtensions': 'Import Failed Extensions'
}
return translations[key] || key
})
}))
}))
// Mock data for conflict detection
const mockConflictData = ref<ConflictDetectionResult[]>([])
// Mock useConflictDetection composable
vi.mock('@/composables/useConflictDetection', () => ({
useConflictDetection: () => ({
conflictedPackages: computed(() => mockConflictData.value)
})
}))
describe('NodeConflictDialogContent', () => {
let pinia: ReturnType<typeof createPinia>
beforeEach(() => {
vi.clearAllMocks()
pinia = createPinia()
setActivePinia(pinia)
// Reset mock data
mockConflictData.value = []
})
const createWrapper = (props = {}) => {
return mount(NodeConflictDialogContent, {
props,
global: {
plugins: [pinia],
components: {
Button
},
stubs: {
ContentDivider: true
},
mocks: {
$t: vi.fn((key: string) => {
const translations: Record<string, string> = {
'manager.conflicts.description':
'Some extensions are not compatible',
'manager.conflicts.info': 'Additional info about conflicts',
'manager.conflicts.conflicts': 'Conflicts',
'manager.conflicts.extensionAtRisk': 'Extensions at Risk',
'manager.conflicts.importFailedExtensions':
'Import Failed Extensions'
}
return translations[key] || key
})
}
}
})
}
const mockConflictResults: ConflictDetectionResult[] = [
{
package_id: 'Package1',
package_name: 'Test Package 1',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'os',
current_value: 'macOS',
required_value: 'Windows'
},
{
type: 'accelerator',
current_value: 'Metal',
required_value: 'CUDA'
}
]
},
{
package_id: 'Package2',
package_name: 'Test Package 2',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'banned',
current_value: 'installed',
required_value: 'not_banned'
}
]
},
{
package_id: 'Package3',
package_name: 'Test Package 3',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'import_failed',
current_value: 'installed',
required_value: 'ModuleNotFoundError: No module named "example"'
}
]
}
]
describe('rendering', () => {
it('should render without conflicts', () => {
// Set empty conflict data
mockConflictData.value = []
const wrapper = createWrapper()
expect(wrapper.text()).toContain('0')
expect(wrapper.text()).toContain('Conflicts')
expect(wrapper.text()).toContain('Extensions at Risk')
expect(wrapper.find('[class*="Import Failed Extensions"]').exists()).toBe(
false
)
})
it('should render with conflict data from composable', () => {
// Set conflict data
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
// Should show 3 total conflicts (2 from Package1 + 1 from Package2, excluding import_failed)
expect(wrapper.text()).toContain('3')
expect(wrapper.text()).toContain('Conflicts')
// Should show 3 extensions at risk (all packages)
expect(wrapper.text()).toContain('Extensions at Risk')
// Should show import failed section
expect(wrapper.text()).toContain('Import Failed Extensions')
expect(wrapper.text()).toContain('1') // 1 import failed package
})
it('should show description when showAfterWhatsNew is true', () => {
const wrapper = createWrapper({
showAfterWhatsNew: true
})
expect(wrapper.text()).toContain('Some extensions are not compatible')
expect(wrapper.text()).toContain('Additional info about conflicts')
})
it('should not show description when showAfterWhatsNew is false', () => {
const wrapper = createWrapper({
showAfterWhatsNew: false
})
expect(wrapper.text()).not.toContain('Some extensions are not compatible')
expect(wrapper.text()).not.toContain('Additional info about conflicts')
})
it('should separate import_failed conflicts into separate section', () => {
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
// Import Failed Extensions section should show 1 package
const importFailedSection = wrapper.findAll(
'.w-full.flex.flex-col.bg-neutral-200'
)[0]
expect(importFailedSection.text()).toContain('1')
expect(importFailedSection.text()).toContain('Import Failed Extensions')
// Conflicts section should show 3 conflicts (excluding import_failed)
const conflictsSection = wrapper.findAll(
'.w-full.flex.flex-col.bg-neutral-200'
)[1]
expect(conflictsSection.text()).toContain('3')
expect(conflictsSection.text()).toContain('Conflicts')
})
})
describe('panel interactions', () => {
beforeEach(() => {
mockConflictData.value = mockConflictResults
})
it('should toggle import failed panel', async () => {
const wrapper = createWrapper()
// Find import failed panel header (first one)
const importFailedHeader = wrapper.find('.w-full.h-8.flex.items-center')
// Initially collapsed
expect(
wrapper.find('[class*="py-2 px-4 flex flex-col gap-2.5"]').exists()
).toBe(false)
// Click to expand import failed panel
await importFailedHeader.trigger('click')
// Should be expanded now and show package name
const expandedContent = wrapper.find(
'[class*="py-2 px-4 flex flex-col gap-2.5"]'
)
expect(expandedContent.exists()).toBe(true)
expect(expandedContent.text()).toContain('Test Package 3')
// Should show chevron-down icon when expanded
const chevronButton = wrapper.findComponent(Button)
expect(chevronButton.props('icon')).toContain('pi-chevron-down')
})
it('should toggle conflicts panel', async () => {
const wrapper = createWrapper()
// Find conflicts panel header (second one)
const conflictsHeader = wrapper.findAll(
'.w-full.h-8.flex.items-center'
)[1]
// Click to expand conflicts panel
await conflictsHeader.trigger('click')
// Should be expanded now
const conflictItems = wrapper.findAll('.conflict-list-item')
expect(conflictItems.length).toBeGreaterThan(0)
})
it('should toggle extensions panel', async () => {
const wrapper = createWrapper()
// Find extensions panel header (third one)
const extensionsHeader = wrapper.findAll(
'.w-full.h-8.flex.items-center'
)[2]
// Click to expand extensions panel
await extensionsHeader.trigger('click')
// Should be expanded now and show all package names
const expandedContent = wrapper.findAll(
'[class*="py-2 px-4 flex flex-col gap-2.5"]'
)[0]
expect(expandedContent.exists()).toBe(true)
expect(expandedContent.text()).toContain('Test Package 1')
expect(expandedContent.text()).toContain('Test Package 2')
expect(expandedContent.text()).toContain('Test Package 3')
})
it('should collapse other panels when opening one', async () => {
const wrapper = createWrapper()
const importFailedHeader = wrapper.findAll(
'.w-full.h-8.flex.items-center'
)[0]
const conflictsHeader = wrapper.findAll(
'.w-full.h-8.flex.items-center'
)[1]
const extensionsHeader = wrapper.findAll(
'.w-full.h-8.flex.items-center'
)[2]
// Open import failed panel first
await importFailedHeader.trigger('click')
// Verify import failed panel is open
expect((wrapper.vm as any).importFailedExpanded).toBe(true)
expect((wrapper.vm as any).conflictsExpanded).toBe(false)
expect((wrapper.vm as any).extensionsExpanded).toBe(false)
// Open conflicts panel
await conflictsHeader.trigger('click')
// Verify conflicts panel is open and others are closed
expect((wrapper.vm as any).importFailedExpanded).toBe(false)
expect((wrapper.vm as any).conflictsExpanded).toBe(true)
expect((wrapper.vm as any).extensionsExpanded).toBe(false)
// Open extensions panel
await extensionsHeader.trigger('click')
// Verify extensions panel is open and others are closed
expect((wrapper.vm as any).importFailedExpanded).toBe(false)
expect((wrapper.vm as any).conflictsExpanded).toBe(false)
expect((wrapper.vm as any).extensionsExpanded).toBe(true)
})
})
describe('conflict display', () => {
beforeEach(() => {
mockConflictData.value = mockConflictResults
})
it('should display individual conflict details excluding import_failed', async () => {
const wrapper = createWrapper()
// Expand conflicts panel (second header)
const conflictsHeader = wrapper.findAll(
'.w-full.h-8.flex.items-center'
)[1]
await conflictsHeader.trigger('click')
// Should display conflict messages (excluding import_failed)
const conflictItems = wrapper.findAll('.conflict-list-item')
expect(conflictItems).toHaveLength(3) // 2 from Package1 + 1 from Package2
})
it('should display import failed packages separately', async () => {
const wrapper = createWrapper()
// Expand import failed panel (first header)
const importFailedHeader = wrapper.findAll(
'.w-full.h-8.flex.items-center'
)[0]
await importFailedHeader.trigger('click')
// Should display only import failed package
const importFailedItems = wrapper.findAll('.conflict-list-item')
expect(importFailedItems).toHaveLength(1)
expect(importFailedItems[0].text()).toContain('Test Package 3')
})
it('should display all package names in extensions list', async () => {
const wrapper = createWrapper()
// Expand extensions panel (third header)
const extensionsHeader = wrapper.findAll(
'.w-full.h-8.flex.items-center'
)[2]
await extensionsHeader.trigger('click')
// Should display all package names
expect(wrapper.text()).toContain('Test Package 1')
expect(wrapper.text()).toContain('Test Package 2')
expect(wrapper.text()).toContain('Test Package 3')
})
})
describe('empty states', () => {
it('should handle empty conflicts gracefully', () => {
mockConflictData.value = []
const wrapper = createWrapper()
expect(wrapper.text()).toContain('0')
expect(wrapper.text()).toContain('Conflicts')
expect(wrapper.text()).toContain('Extensions at Risk')
// Import failed section should not be visible when there are no import failures
expect(wrapper.text()).not.toContain('Import Failed Extensions')
})
it('should handle conflicts without import_failed', () => {
// Only set packages without import_failed conflicts
mockConflictData.value = [mockConflictResults[0], mockConflictResults[1]]
const wrapper = createWrapper()
expect(wrapper.text()).toContain('3') // conflicts count
expect(wrapper.text()).toContain('2') // extensions count
// Import failed section should not be visible
expect(wrapper.text()).not.toContain('Import Failed Extensions')
})
})
describe('scrolling behavior', () => {
it('should apply scrollbar styles to all expandable lists', async () => {
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
// Test all three panels
const headers = wrapper.findAll('.w-full.h-8.flex.items-center')
for (let i = 0; i < headers.length; i++) {
await headers[i].trigger('click')
// Check for scrollable container with proper classes
const scrollableContainer = wrapper.find(
'[class*="max-h-"][class*="overflow-y-auto"][class*="scrollbar-hide"]'
)
expect(scrollableContainer.exists()).toBe(true)
// Close the panel for next iteration
await headers[i].trigger('click')
}
})
})
describe('accessibility', () => {
it('should have proper button roles and labels', () => {
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
const buttons = wrapper.findAllComponents(Button)
expect(buttons.length).toBe(3) // 3 chevron buttons
// Check chevron buttons have icons
buttons.forEach((button) => {
expect(button.props('icon')).toBeDefined()
expect(button.props('icon')).toMatch(/pi-chevron-(right|down)/)
})
})
it('should have clickable panel headers', () => {
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
const headers = wrapper.findAll('.w-full.h-8.flex.items-center')
expect(headers).toHaveLength(3) // import failed, conflicts and extensions headers
headers.forEach((header) => {
expect(header.element.tagName).toBe('DIV')
})
})
})
describe('es-toolkit optimization', () => {
it('should efficiently filter conflicts using es-toolkit', () => {
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
// Verify that import_failed conflicts are filtered out from main conflicts
const vm = wrapper.vm as any
expect(vm.allConflictDetails).toHaveLength(3) // Should not include import_failed
expect(
vm.allConflictDetails.every((c: any) => c.type !== 'import_failed')
).toBe(true)
})
it('should efficiently extract import failed packages using es-toolkit', () => {
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
// Verify that only import_failed packages are extracted
const vm = wrapper.vm as any
expect(vm.importFailedConflicts).toHaveLength(1)
expect(vm.importFailedConflicts[0]).toBe('Test Package 3')
})
})
})

View File

@@ -0,0 +1,229 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import Card from 'primevue/card'
import ProgressSpinner from 'primevue/progressspinner'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PackCard from '@/components/dialog/content/manager/packCard/PackCard.vue'
import type { MergedNodePack, RegistryPack } from '@/types/comfyManagerTypes'
// Mock dependencies
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
d: vi.fn(() => '2024. 1. 1.'),
t: vi.fn((key: string) => key)
})),
createI18n: vi.fn(() => ({
global: {
t: vi.fn((key: string) => key),
te: vi.fn(() => true)
}
}))
}))
vi.mock('@/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
isPackInstalled: vi.fn(() => false),
isPackEnabled: vi.fn(() => true),
isPackInstalling: vi.fn(() => false),
installedPacksIds: []
}))
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: vi.fn(() => ({
completedActivePalette: { light_theme: true }
}))
}))
vi.mock('@vueuse/core', async () => {
const { ref } = await import('vue')
return {
whenever: vi.fn(),
useStorage: vi.fn((_key, defaultValue) => {
return ref(defaultValue)
})
}
})
vi.mock('@/config', () => ({
default: {
app_version: '1.24.0-1'
}
}))
vi.mock('@/stores/systemStatsStore', () => ({
useSystemStatsStore: vi.fn(() => ({
systemStats: {
system: { os: 'Darwin' },
devices: [{ type: 'mps', name: 'Metal' }]
}
}))
}))
describe('PackCard', () => {
let pinia: ReturnType<typeof createPinia>
beforeEach(() => {
vi.clearAllMocks()
pinia = createPinia()
setActivePinia(pinia)
})
const createWrapper = (props: {
nodePack: MergedNodePack | RegistryPack
isSelected?: boolean
}) => {
const wrapper = mount(PackCard, {
props,
global: {
plugins: [pinia],
components: {
Card,
ProgressSpinner
},
stubs: {
PackBanner: true,
PackVersionBadge: true,
PackCardFooter: true
},
mocks: {
$t: vi.fn((key: string) => key)
}
}
})
return wrapper
}
const mockNodePack: RegistryPack = {
id: 'test-package',
name: 'Test Package',
description: 'Test package description',
author: 'Test Author',
latest_version: {
createdAt: '2024-01-01T00:00:00Z'
}
} as RegistryPack
describe('basic rendering', () => {
it('should render package card with basic information', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.find('.p-card').exists()).toBe(true)
expect(wrapper.text()).toContain('Test Package')
expect(wrapper.text()).toContain('Test package description')
expect(wrapper.text()).toContain('Test Author')
})
it('should render date correctly', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.text()).toContain('2024. 1. 1.')
})
it('should apply selected class when isSelected is true', () => {
const wrapper = createWrapper({
nodePack: mockNodePack,
isSelected: true
})
expect(wrapper.find('.selected-card').exists()).toBe(true)
})
it('should not apply selected class when isSelected is false', () => {
const wrapper = createWrapper({
nodePack: mockNodePack,
isSelected: false
})
expect(wrapper.find('.selected-card').exists()).toBe(false)
})
})
describe('component behavior', () => {
it('should render without errors', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.exists()).toBe(true)
expect(wrapper.find('.p-card').exists()).toBe(true)
})
})
describe('package information display', () => {
it('should display package name', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.text()).toContain('Test Package')
})
it('should display package description', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.text()).toContain('Test package description')
})
it('should display author name', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.text()).toContain('Test Author')
})
it('should handle missing description', () => {
const packWithoutDescription = { ...mockNodePack, description: undefined }
const wrapper = createWrapper({ nodePack: packWithoutDescription })
expect(wrapper.find('p').exists()).toBe(false)
})
it('should handle missing author', () => {
const packWithoutAuthor = { ...mockNodePack, author: undefined }
const wrapper = createWrapper({ nodePack: packWithoutAuthor })
// Should still render without errors
expect(wrapper.exists()).toBe(true)
})
})
describe('component structure', () => {
it('should render PackBanner component', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.find('pack-banner-stub').exists()).toBe(true)
})
it('should render PackVersionBadge component', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.find('pack-version-badge-stub').exists()).toBe(true)
})
it('should render PackCardFooter component', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.find('pack-card-footer-stub').exists()).toBe(true)
})
})
describe('styling', () => {
it('should have correct CSS classes', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
const card = wrapper.find('.p-card')
expect(card.classes()).toContain('w-full')
expect(card.classes()).toContain('h-full')
expect(card.classes()).toContain('rounded-lg')
})
it('should have correct base styling', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
const card = wrapper.find('.p-card')
// Check the actual classes applied to the card
expect(card.classes()).toContain('p-card')
expect(card.classes()).toContain('p-component')
expect(card.classes()).toContain('inline-flex')
expect(card.classes()).toContain('flex-col')
})
})
})

View File

@@ -0,0 +1,478 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import ManagerProgressFooter from '@/components/dialog/footer/ManagerProgressFooter.vue'
import { useComfyManagerService } from '@/services/comfyManagerService'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/stores/comfyManagerStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useSettingStore } from '@/stores/settingStore'
import { TaskLog } from '@/types/comfyManagerTypes'
// Mock modules
vi.mock('@/stores/comfyManagerStore')
vi.mock('@/stores/dialogStore')
vi.mock('@/stores/settingStore')
vi.mock('@/stores/commandStore')
vi.mock('@/services/comfyManagerService')
vi.mock('@/composables/useConflictDetection', () => ({
useConflictDetection: vi.fn(() => ({
conflictedPackages: { value: [] },
performConflictDetection: vi.fn().mockResolvedValue(undefined)
}))
}))
// Mock useEventListener to capture the event handler
let reconnectHandler: (() => void) | null = null
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
return {
...actual,
useEventListener: vi.fn(
(_target: any, event: string, handler: any, _options: any) => {
if (event === 'reconnected') {
reconnectHandler = handler
}
}
)
}
})
vi.mock('@/services/workflowService', () => ({
useWorkflowService: vi.fn(() => ({
reloadCurrentWorkflow: vi.fn().mockResolvedValue(undefined)
}))
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: vi.fn(() => ({
completedActivePalette: {
light_theme: false
}
}))
}))
// Helper function to mount component with required setup
const mountComponent = (options: { captureError?: boolean } = {}) => {
const pinia = createPinia()
setActivePinia(pinia)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
progressCountOf: 'of'
},
manager: {
clickToFinishSetup: 'Click',
applyChanges: 'Apply Changes',
toFinishSetup: 'to finish setup',
restartingBackend: 'Restarting backend to apply changes...',
extensionsSuccessfullyInstalled:
'Extension(s) successfully installed and are ready to use!',
restartToApplyChanges: 'To apply changes, please restart ComfyUI',
installingDependencies: 'Installing dependencies...'
}
}
}
})
const config: any = {
global: {
plugins: [pinia, PrimeVue, i18n]
}
}
// Add error handler for tests that expect errors
if (options.captureError) {
config.global.config = {
errorHandler: () => {
// Suppress error in test
}
}
}
return mount(ManagerProgressFooter, config)
}
describe('ManagerProgressFooter', () => {
const mockTaskLogs: TaskLog[] = []
const mockComfyManagerStore = {
taskLogs: mockTaskLogs,
allTasksDone: true,
isProcessingTasks: false,
succeededTasksIds: [] as string[],
failedTasksIds: [] as string[],
taskHistory: {} as Record<string, any>,
taskQueue: null,
resetTaskState: vi.fn(),
clearLogs: vi.fn(),
setStale: vi.fn(),
// Add other required properties
isLoading: { value: false },
error: { value: null },
statusMessage: { value: 'DONE' },
installedPacks: {},
installedPacksIds: new Set(),
isPackInstalled: vi.fn(),
isPackEnabled: vi.fn(),
getInstalledPackVersion: vi.fn(),
refreshInstalledList: vi.fn(),
installPack: vi.fn(),
uninstallPack: vi.fn(),
updatePack: vi.fn(),
updateAllPacks: vi.fn(),
disablePack: vi.fn(),
enablePack: vi.fn()
}
const mockDialogStore = {
closeDialog: vi.fn(),
// Add other required properties
dialogStack: { value: [] },
showDialog: vi.fn(),
$id: 'dialog',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$dispose: vi.fn(),
$onAction: vi.fn()
}
const mockSettingStore = {
get: vi.fn().mockReturnValue(false),
set: vi.fn(),
// Add other required properties
settingValues: { value: {} },
settingsById: { value: {} },
exists: vi.fn(),
getDefaultValue: vi.fn(),
loadSettingValues: vi.fn(),
updateValue: vi.fn(),
$id: 'setting',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$dispose: vi.fn(),
$onAction: vi.fn()
}
const mockProgressDialogStore = {
isExpanded: false,
toggle: vi.fn(),
collapse: vi.fn(),
expand: vi.fn()
}
const mockCommandStore = {
execute: vi.fn().mockResolvedValue(undefined)
}
const mockComfyManagerService = {
rebootComfyUI: vi.fn().mockResolvedValue(null)
}
beforeEach(() => {
vi.clearAllMocks()
// Create new pinia instance for each test
const pinia = createPinia()
setActivePinia(pinia)
// Reset task logs
mockTaskLogs.length = 0
mockComfyManagerStore.taskLogs = mockTaskLogs
// Reset event handler
reconnectHandler = null
vi.mocked(useComfyManagerStore).mockReturnValue(
mockComfyManagerStore as any
)
vi.mocked(useDialogStore).mockReturnValue(mockDialogStore as any)
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any)
vi.mocked(useManagerProgressDialogStore).mockReturnValue(
mockProgressDialogStore as any
)
vi.mocked(useCommandStore).mockReturnValue(mockCommandStore as any)
vi.mocked(useComfyManagerService).mockReturnValue(
mockComfyManagerService as any
)
})
describe('State 1: Queue Running', () => {
it('should display loading spinner and progress counter when queue is running', async () => {
// Setup queue running state
mockComfyManagerStore.isProcessingTasks = true
mockComfyManagerStore.succeededTasksIds = ['1', '2']
mockComfyManagerStore.failedTasksIds = []
mockComfyManagerStore.taskHistory = {
'1': { taskName: 'Installing pack1' },
'2': { taskName: 'Installing pack2' },
'3': { taskName: 'Installing pack3' }
}
mockTaskLogs.push(
{ taskName: 'Installing pack1', taskId: '1', logs: [] },
{ taskName: 'Installing pack2', taskId: '2', logs: [] },
{ taskName: 'Installing pack3', taskId: '3', logs: [] }
)
const wrapper = mountComponent()
// Check loading spinner exists (DotSpinner component)
expect(wrapper.find('.inline-flex').exists()).toBe(true)
// Check current task name is displayed
expect(wrapper.text()).toContain('Installing pack3')
// Check progress counter (completed: 2 of 3)
expect(wrapper.text()).toMatch(/2.*of.*3/)
// Check expand/collapse button exists
const expandButton = wrapper.find('[aria-label="Expand"]')
expect(expandButton.exists()).toBe(true)
// Check Apply Changes button is NOT shown
expect(wrapper.text()).not.toContain('Apply Changes')
})
it('should toggle expansion when expand button is clicked', async () => {
mockComfyManagerStore.isProcessingTasks = true
mockTaskLogs.push({ taskName: 'Installing', taskId: '1', logs: [] })
const wrapper = mountComponent()
const expandButton = wrapper.find('[aria-label="Expand"]')
await expandButton.trigger('click')
expect(mockProgressDialogStore.toggle).toHaveBeenCalled()
})
})
describe('State 2: Tasks Completed (Waiting for Restart)', () => {
it('should display check mark and Apply Changes button when all tasks are done', async () => {
// Setup tasks completed state
mockComfyManagerStore.isProcessingTasks = false
mockTaskLogs.push(
{ taskName: 'Installed pack1', taskId: '1', logs: [] },
{ taskName: 'Installed pack2', taskId: '2', logs: [] }
)
mockComfyManagerStore.allTasksDone = true
const wrapper = mountComponent()
// Check check mark emoji
expect(wrapper.text()).toContain('✅')
// Check restart message
expect(wrapper.text()).toContain(
'To apply changes, please restart ComfyUI'
)
expect(wrapper.text()).toContain('Apply Changes')
// Check Apply Changes button exists
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
expect(applyButton).toBeTruthy()
// Check no progress counter
expect(wrapper.text()).not.toMatch(/\d+.*of.*\d+/)
})
})
describe('State 3: Restarting', () => {
it('should display restarting message and spinner during restart', async () => {
// Setup completed state first
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
const wrapper = mountComponent()
// Click Apply Changes to trigger restart
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
await applyButton?.trigger('click')
// Wait for state update
await nextTick()
// Check restarting message
expect(wrapper.text()).toContain('Restarting backend to apply changes...')
// Check loading spinner during restart
expect(wrapper.find('.inline-flex').exists()).toBe(true)
// Check Apply Changes button is hidden
expect(wrapper.text()).not.toContain('Apply Changes')
})
})
describe('State 4: Restart Completed', () => {
it('should display success message and auto-close after 3 seconds', async () => {
vi.useFakeTimers()
// Setup completed state
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
const wrapper = mountComponent()
// Trigger restart
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
await applyButton?.trigger('click')
// Wait for event listener to be set up
await nextTick()
// Trigger the reconnect handler directly
if (reconnectHandler) {
await reconnectHandler()
}
// Wait for restart completed state
await nextTick()
// Check success message
expect(wrapper.text()).toContain('🎉')
expect(wrapper.text()).toContain(
'Extension(s) successfully installed and are ready to use!'
)
// Check dialog closes after 3 seconds
vi.advanceTimersByTime(3000)
await nextTick()
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'global-manager-progress-dialog'
})
expect(mockComfyManagerStore.resetTaskState).toHaveBeenCalled()
vi.useRealTimers()
})
})
describe('Common Features', () => {
it('should always display close button', async () => {
const wrapper = mountComponent()
const closeButton = wrapper.find('[aria-label="Close"]')
expect(closeButton.exists()).toBe(true)
})
it('should close dialog when close button is clicked', async () => {
const wrapper = mountComponent()
const closeButton = wrapper.find('[aria-label="Close"]')
await closeButton.trigger('click')
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'global-manager-progress-dialog'
})
})
})
describe('Toast Management', () => {
it('should suppress reconnection toasts during restart', async () => {
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
mockSettingStore.get.mockReturnValue(false) // Original setting
const wrapper = mountComponent()
// Click Apply Changes
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
await applyButton?.trigger('click')
// Check toast setting was disabled
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Toast.DisableReconnectingToast',
true
)
})
it('should restore toast settings after restart completes', async () => {
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
mockSettingStore.get.mockReturnValue(false) // Original setting
const wrapper = mountComponent()
// Click Apply Changes
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
await applyButton?.trigger('click')
// Wait for event listener to be set up
await nextTick()
// Trigger the reconnect handler directly
if (reconnectHandler) {
await reconnectHandler()
}
// Wait for settings restoration
await nextTick()
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Toast.DisableReconnectingToast',
false // Restored to original
)
})
})
describe('Error Handling', () => {
it('should restore state and close dialog on restart error', async () => {
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
// Mock restart to throw error
mockComfyManagerService.rebootComfyUI.mockRejectedValue(
new Error('Restart failed')
)
const wrapper = mountComponent({ captureError: true })
// Click Apply Changes
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
expect(applyButton).toBeTruthy()
// The component throws the error but Vue Test Utils catches it
// We need to check if the error handling logic was executed
await applyButton!.trigger('click').catch(() => {
// Error is expected, ignore it
})
// Wait for error handling
await nextTick()
// Check dialog was closed on error
expect(mockDialogStore.closeDialog).toHaveBeenCalled()
// Check toast settings were restored
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Toast.DisableReconnectingToast',
false
)
// Check that the error handler was called
expect(mockComfyManagerService.rebootComfyUI).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,433 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import WhatsNewPopup from '@/components/helpcenter/WhatsNewPopup.vue'
import type { components } from '@/types/comfyRegistryTypes'
type ReleaseNote = components['schemas']['ReleaseNote']
// Mock dependencies
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
locale: { value: 'en' },
t: vi.fn((key) => key)
}))
}))
vi.mock('marked', () => ({
marked: vi.fn((content) => `<p>${content}</p>`)
}))
vi.mock('@/stores/releaseStore', () => ({
useReleaseStore: vi.fn()
}))
describe('WhatsNewPopup', () => {
const mockReleaseStore = {
recentRelease: null as ReleaseNote | null,
shouldShowPopup: false,
handleWhatsNewSeen: vi.fn(),
releases: [] as ReleaseNote[],
fetchReleases: vi.fn()
}
const createWrapper = (props = {}) => {
return mount(WhatsNewPopup, {
props,
global: {
mocks: {
$t: vi.fn((key: string) => {
const translations: Record<string, string> = {
'g.close': 'Close',
'whatsNewPopup.noReleaseNotes': 'No release notes available'
}
return translations[key] || key
})
}
}
})
}
beforeEach(async () => {
vi.clearAllMocks()
// Reset mock store
mockReleaseStore.recentRelease = null
mockReleaseStore.shouldShowPopup = false
mockReleaseStore.releases = []
// Mock release store
const { useReleaseStore } = await import('@/stores/releaseStore')
vi.mocked(useReleaseStore).mockReturnValue(mockReleaseStore as any)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('visibility', () => {
it('should not show when shouldShowPopup is false', () => {
mockReleaseStore.shouldShowPopup = false
const wrapper = createWrapper()
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(false)
})
it('should show when shouldShowPopup is true and not dismissed', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'New features added',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(true)
expect(wrapper.find('.whats-new-popup').exists()).toBe(true)
})
it('should hide when dismissed locally', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'New features added',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
// Initially visible
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(true)
// Click close button
await wrapper.find('.close-button').trigger('click')
// Should be hidden
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(false)
})
})
describe('content rendering', () => {
it('should render release content using marked', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: '# Release Notes\n\nNew features',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
// Check that the content is rendered (marked is mocked to return processed content)
expect(wrapper.find('.content-text').exists()).toBe(true)
const contentHtml = wrapper.find('.content-text').html()
expect(contentHtml).toContain('<p># Release Notes')
})
it('should handle missing release content', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: '',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
expect(wrapper.find('.content-text').html()).toContain(
'whatsNewPopup.noReleaseNotes'
)
})
it('should handle markdown parsing errors gracefully', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'Content with\nnewlines',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
// Should show content even without markdown processing
expect(wrapper.find('.content-text').exists()).toBe(true)
})
})
describe('changelog URL generation', () => {
it('should generate English changelog URL with version anchor', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0-beta.1',
attention: 'medium',
content: 'Release content',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
const learnMoreLink = wrapper.find('.learn-more-link')
// formatVersionAnchor replaces dots with dashes: 1.24.0-beta.1 -> v1-24-0-beta-1
expect(learnMoreLink.attributes('href')).toBe(
'https://docs.comfy.org/changelog#v1-24-0-beta-1'
)
})
it('should generate Chinese changelog URL when locale is zh', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'Release content',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper({
global: {
mocks: {
$t: vi.fn((key: string) => {
const translations: Record<string, string> = {
'g.close': 'Close',
'whatsNewPopup.noReleaseNotes': 'No release notes available',
'whatsNewPopup.learnMore': 'Learn More'
}
return translations[key] || key
})
},
provide: {
// Mock vue-i18n locale as Chinese
locale: { value: 'zh' }
}
}
})
// Since the locale mocking doesn't work well in tests, just check the English URL for now
// In a real component test with proper i18n setup, this would show the Chinese URL
const learnMoreLink = wrapper.find('.learn-more-link')
expect(learnMoreLink.attributes('href')).toBe(
'https://docs.comfy.org/changelog#v1-24-0'
)
})
it('should generate base changelog URL when no version available', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '',
attention: 'medium',
content: 'Release content',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
const learnMoreLink = wrapper.find('.learn-more-link')
expect(learnMoreLink.attributes('href')).toBe(
'https://docs.comfy.org/changelog'
)
})
})
describe('popup dismissal', () => {
it('should call handleWhatsNewSeen and emit event when closed', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'Release content',
published_at: '2023-01-01T00:00:00Z'
}
mockReleaseStore.handleWhatsNewSeen.mockResolvedValue(undefined)
const wrapper = createWrapper()
// Click close button
await wrapper.find('.close-button').trigger('click')
expect(mockReleaseStore.handleWhatsNewSeen).toHaveBeenCalledWith('1.24.0')
expect(wrapper.emitted('whats-new-dismissed')).toBeTruthy()
expect(wrapper.emitted('whats-new-dismissed')).toHaveLength(1)
})
it('should close when learn more link is clicked', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'Release content',
published_at: '2023-01-01T00:00:00Z'
}
mockReleaseStore.handleWhatsNewSeen.mockResolvedValue(undefined)
const wrapper = createWrapper()
// Click learn more link
await wrapper.find('.learn-more-link').trigger('click')
expect(mockReleaseStore.handleWhatsNewSeen).toHaveBeenCalledWith('1.24.0')
expect(wrapper.emitted('whats-new-dismissed')).toBeTruthy()
})
it('should handle cases where no release is available during close', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = null
const wrapper = createWrapper()
// Try to close
await wrapper.find('.close-button').trigger('click')
expect(mockReleaseStore.handleWhatsNewSeen).not.toHaveBeenCalled()
expect(wrapper.emitted('whats-new-dismissed')).toBeTruthy()
})
})
describe('exposed methods', () => {
it('should expose show and hide methods', () => {
const wrapper = createWrapper()
expect(wrapper.vm.show).toBeDefined()
expect(wrapper.vm.hide).toBeDefined()
expect(typeof wrapper.vm.show).toBe('function')
expect(typeof wrapper.vm.hide).toBe('function')
})
it('should show popup when show method is called', async () => {
mockReleaseStore.shouldShowPopup = true
const wrapper = createWrapper()
// Initially hide it
wrapper.vm.hide()
await nextTick()
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(false)
// Show it
wrapper.vm.show()
await nextTick()
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(true)
})
it('should hide popup when hide method is called', async () => {
mockReleaseStore.shouldShowPopup = true
const wrapper = createWrapper()
// Initially visible
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(true)
// Hide it
wrapper.vm.hide()
await nextTick()
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(false)
})
})
describe('initialization', () => {
it('should fetch releases on mount if not already loaded', async () => {
mockReleaseStore.releases = []
mockReleaseStore.fetchReleases.mockResolvedValue(undefined)
createWrapper()
// Wait for onMounted
await nextTick()
expect(mockReleaseStore.fetchReleases).toHaveBeenCalled()
})
it('should not fetch releases if already loaded', async () => {
mockReleaseStore.releases = [
{
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium' as const,
content: 'Content',
published_at: '2023-01-01T00:00:00Z'
}
]
mockReleaseStore.fetchReleases.mockResolvedValue(undefined)
createWrapper()
// Wait for onMounted
await nextTick()
expect(mockReleaseStore.fetchReleases).not.toHaveBeenCalled()
})
})
describe('accessibility', () => {
it('should have proper aria-label for close button', () => {
const mockT = vi.fn((key) => (key === 'g.close' ? 'Close' : key))
vi.doMock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
locale: { value: 'en' },
t: mockT
}))
}))
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'Content',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
expect(wrapper.find('.close-button').attributes('aria-label')).toBe(
'Close'
)
})
it('should have proper link attributes for external changelog', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'Content',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
const learnMoreLink = wrapper.find('.learn-more-link')
expect(learnMoreLink.attributes('target')).toBe('_blank')
expect(learnMoreLink.attributes('rel')).toBe('noopener,noreferrer')
})
})
})

View File

@@ -0,0 +1,351 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { useTransformState } from '@/composables/element/useTransformState'
// Create a mock canvas context for transform testing
function createMockCanvasContext() {
return {
canvas: {
width: 1280,
height: 720,
getBoundingClientRect: () => ({
left: 0,
top: 0,
width: 1280,
height: 720,
right: 1280,
bottom: 720,
x: 0,
y: 0
})
},
ds: {
offset: [0, 0],
scale: 1
}
}
}
describe('useTransformState', () => {
let transformState: ReturnType<typeof useTransformState>
beforeEach(() => {
transformState = useTransformState()
})
describe('initial state', () => {
it('should initialize with default camera values', () => {
const { camera } = transformState
expect(camera.x).toBe(0)
expect(camera.y).toBe(0)
expect(camera.z).toBe(1)
})
it('should generate correct initial transform style', () => {
const { transformStyle } = transformState
expect(transformStyle.value).toEqual({
transform: 'scale(1) translate(0px, 0px)',
transformOrigin: '0 0'
})
})
})
describe('syncWithCanvas', () => {
it('should sync camera state with canvas transform', () => {
const { syncWithCanvas, camera } = transformState
const mockCanvas = createMockCanvasContext()
// Set mock canvas transform
mockCanvas.ds.offset = [100, 50]
mockCanvas.ds.scale = 2
syncWithCanvas(mockCanvas as any)
expect(camera.x).toBe(100)
expect(camera.y).toBe(50)
expect(camera.z).toBe(2)
})
it('should handle null canvas gracefully', () => {
const { syncWithCanvas, camera } = transformState
syncWithCanvas(null as any)
// Should remain at initial values
expect(camera.x).toBe(0)
expect(camera.y).toBe(0)
expect(camera.z).toBe(1)
})
it('should handle canvas without ds property', () => {
const { syncWithCanvas, camera } = transformState
const canvasWithoutDs = { canvas: {} }
syncWithCanvas(canvasWithoutDs as any)
// Should remain at initial values
expect(camera.x).toBe(0)
expect(camera.y).toBe(0)
expect(camera.z).toBe(1)
})
it('should update transform style after sync', () => {
const { syncWithCanvas, transformStyle } = transformState
const mockCanvas = createMockCanvasContext()
mockCanvas.ds.offset = [150, 75]
mockCanvas.ds.scale = 0.5
syncWithCanvas(mockCanvas as any)
expect(transformStyle.value).toEqual({
transform: 'scale(0.5) translate(150px, 75px)',
transformOrigin: '0 0'
})
})
})
describe('coordinate conversions', () => {
beforeEach(() => {
// Set up a known transform state
const mockCanvas = createMockCanvasContext()
mockCanvas.ds.offset = [100, 50]
mockCanvas.ds.scale = 2
transformState.syncWithCanvas(mockCanvas as any)
})
describe('canvasToScreen', () => {
it('should convert canvas coordinates to screen coordinates', () => {
const { canvasToScreen } = transformState
const canvasPoint = { x: 10, y: 20 }
const screenPoint = canvasToScreen(canvasPoint)
// screen = canvas * scale + offset
// x: 10 * 2 + 100 = 120
// y: 20 * 2 + 50 = 90
expect(screenPoint).toEqual({ x: 120, y: 90 })
})
it('should handle zero coordinates', () => {
const { canvasToScreen } = transformState
const screenPoint = canvasToScreen({ x: 0, y: 0 })
expect(screenPoint).toEqual({ x: 100, y: 50 })
})
it('should handle negative coordinates', () => {
const { canvasToScreen } = transformState
const screenPoint = canvasToScreen({ x: -10, y: -20 })
expect(screenPoint).toEqual({ x: 80, y: 10 })
})
})
describe('screenToCanvas', () => {
it('should convert screen coordinates to canvas coordinates', () => {
const { screenToCanvas } = transformState
const screenPoint = { x: 120, y: 90 }
const canvasPoint = screenToCanvas(screenPoint)
// canvas = (screen - offset) / scale
// x: (120 - 100) / 2 = 10
// y: (90 - 50) / 2 = 20
expect(canvasPoint).toEqual({ x: 10, y: 20 })
})
it('should be inverse of canvasToScreen', () => {
const { canvasToScreen, screenToCanvas } = transformState
const originalPoint = { x: 25, y: 35 }
const screenPoint = canvasToScreen(originalPoint)
const backToCanvas = screenToCanvas(screenPoint)
expect(backToCanvas.x).toBeCloseTo(originalPoint.x)
expect(backToCanvas.y).toBeCloseTo(originalPoint.y)
})
})
})
describe('getNodeScreenBounds', () => {
beforeEach(() => {
const mockCanvas = createMockCanvasContext()
mockCanvas.ds.offset = [100, 50]
mockCanvas.ds.scale = 2
transformState.syncWithCanvas(mockCanvas as any)
})
it('should calculate correct screen bounds for a node', () => {
const { getNodeScreenBounds } = transformState
const nodePos = [10, 20]
const nodeSize = [200, 100]
const bounds = getNodeScreenBounds(nodePos, nodeSize)
// Top-left: canvasToScreen(10, 20) = (120, 90)
// Width: 200 * 2 = 400
// Height: 100 * 2 = 200
expect(bounds.x).toBe(120)
expect(bounds.y).toBe(90)
expect(bounds.width).toBe(400)
expect(bounds.height).toBe(200)
})
})
describe('isNodeInViewport', () => {
beforeEach(() => {
const mockCanvas = createMockCanvasContext()
mockCanvas.ds.offset = [0, 0]
mockCanvas.ds.scale = 1
transformState.syncWithCanvas(mockCanvas as any)
})
const viewport = { width: 1000, height: 600 }
it('should return true for nodes inside viewport', () => {
const { isNodeInViewport } = transformState
const nodePos = [100, 100]
const nodeSize = [200, 100]
expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(true)
})
it('should return false for nodes completely outside viewport', () => {
const { isNodeInViewport } = transformState
// Node far to the right
expect(isNodeInViewport([2000, 100], [200, 100], viewport)).toBe(false)
// Node far to the left
expect(isNodeInViewport([-500, 100], [200, 100], viewport)).toBe(false)
// Node far below
expect(isNodeInViewport([100, 1000], [200, 100], viewport)).toBe(false)
// Node far above
expect(isNodeInViewport([100, -500], [200, 100], viewport)).toBe(false)
})
it('should return true for nodes partially in viewport with margin', () => {
const { isNodeInViewport } = transformState
// Node slightly outside but within margin
const nodePos = [-50, -50]
const nodeSize = [100, 100]
expect(isNodeInViewport(nodePos, nodeSize, viewport, 0.2)).toBe(true)
})
it('should return false for tiny nodes (size culling)', () => {
const { isNodeInViewport } = transformState
// Node is in viewport but too small
const nodePos = [100, 100]
const nodeSize = [3, 3] // Less than 4 pixels
expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(false)
})
it('should adjust margin based on zoom level', () => {
const { isNodeInViewport, syncWithCanvas } = transformState
const mockCanvas = createMockCanvasContext()
// Test with very low zoom
mockCanvas.ds.scale = 0.05
syncWithCanvas(mockCanvas as any)
// Node at edge should still be visible due to increased margin
expect(isNodeInViewport([1100, 100], [200, 100], viewport)).toBe(true)
// Test with high zoom
mockCanvas.ds.scale = 4
syncWithCanvas(mockCanvas as any)
// Margin should be tighter
expect(isNodeInViewport([1100, 100], [200, 100], viewport)).toBe(false)
})
})
describe('getViewportBounds', () => {
beforeEach(() => {
const mockCanvas = createMockCanvasContext()
mockCanvas.ds.offset = [100, 50]
mockCanvas.ds.scale = 2
transformState.syncWithCanvas(mockCanvas as any)
})
it('should calculate viewport bounds in canvas coordinates', () => {
const { getViewportBounds } = transformState
const viewport = { width: 1000, height: 600 }
const bounds = getViewportBounds(viewport, 0.2)
// With 20% margin:
// marginX = 1000 * 0.2 = 200
// marginY = 600 * 0.2 = 120
// topLeft in screen: (-200, -120)
// bottomRight in screen: (1200, 720)
// Convert to canvas coordinates:
// topLeft: ((-200 - 100) / 2, (-120 - 50) / 2) = (-150, -85)
// bottomRight: ((1200 - 100) / 2, (720 - 50) / 2) = (550, 335)
expect(bounds.x).toBe(-150)
expect(bounds.y).toBe(-85)
expect(bounds.width).toBe(700) // 550 - (-150)
expect(bounds.height).toBe(420) // 335 - (-85)
})
it('should handle zero margin', () => {
const { getViewportBounds } = transformState
const viewport = { width: 1000, height: 600 }
const bounds = getViewportBounds(viewport, 0)
// No margin, so viewport bounds are exact
expect(bounds.x).toBe(-50) // (0 - 100) / 2
expect(bounds.y).toBe(-25) // (0 - 50) / 2
expect(bounds.width).toBe(500) // 1000 / 2
expect(bounds.height).toBe(300) // 600 / 2
})
})
describe('edge cases', () => {
it('should handle extreme zoom levels', () => {
const { syncWithCanvas, canvasToScreen } = transformState
const mockCanvas = createMockCanvasContext()
// Very small zoom
mockCanvas.ds.scale = 0.001
syncWithCanvas(mockCanvas as any)
const point1 = canvasToScreen({ x: 1000, y: 1000 })
expect(point1.x).toBeCloseTo(1)
expect(point1.y).toBeCloseTo(1)
// Very large zoom
mockCanvas.ds.scale = 100
syncWithCanvas(mockCanvas as any)
const point2 = canvasToScreen({ x: 1, y: 1 })
expect(point2.x).toBe(100)
expect(point2.y).toBe(100)
})
it('should handle zero scale in screenToCanvas', () => {
const { syncWithCanvas, screenToCanvas } = transformState
const mockCanvas = createMockCanvasContext()
// Scale of 0 gets converted to 1 by || operator
mockCanvas.ds.scale = 0
syncWithCanvas(mockCanvas as any)
// Should use scale of 1 due to camera.z || 1 in implementation
const result = screenToCanvas({ x: 100, y: 100 })
expect(result.x).toBe(100) // (100 - 0) / 1
expect(result.y).toBe(100) // (100 - 0) / 1
})
})
})

View File

@@ -1,126 +1,189 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { app } from '@/scripts/app'
import * as settingStore from '@/stores/settingStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
// Mock the app and canvas
// Mock stores
vi.mock('@/stores/graphStore')
vi.mock('@/stores/settingStore')
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
canvas: null as HTMLCanvasElement | null
canvas: {
dispatchEvent: vi.fn()
}
}
}
}))
// Mock the setting store
vi.mock('@/stores/settingStore', () => ({
useSettingStore: vi.fn()
}))
describe('useCanvasInteractions', () => {
let mockCanvas: HTMLCanvasElement
let mockSettingStore: { get: ReturnType<typeof vi.fn> }
let canvasInteractions: ReturnType<typeof useCanvasInteractions>
beforeEach(() => {
// Clear mocks
vi.clearAllMocks()
vi.mocked(useCanvasStore, { partial: true }).mockReturnValue({
getCanvas: vi.fn()
})
vi.mocked(useSettingStore, { partial: true }).mockReturnValue({
get: vi.fn()
})
})
// Create mock canvas element
mockCanvas = document.createElement('canvas')
mockCanvas.dispatchEvent = vi.fn()
app.canvas!.canvas = mockCanvas
describe('handlePointer', () => {
it('should forward space+drag events to canvas when read_only is true', () => {
// Setup
const mockCanvas = { read_only: true }
const { getCanvas } = useCanvasStore()
vi.mocked(getCanvas).mockReturnValue(mockCanvas as any)
// Mock setting store
mockSettingStore = { get: vi.fn() }
vi.mocked(settingStore.useSettingStore).mockReturnValue(
mockSettingStore as any
)
const { handlePointer } = useCanvasInteractions()
canvasInteractions = useCanvasInteractions()
// Create mock pointer event
const mockEvent = {
buttons: 1, // Left mouse button
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} satisfies Partial<PointerEvent>
// Test
handlePointer(mockEvent as unknown as PointerEvent)
// Verify
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
})
it('should forward middle mouse button events to canvas', () => {
// Setup
const mockCanvas = { read_only: false }
const { getCanvas } = useCanvasStore()
vi.mocked(getCanvas).mockReturnValue(mockCanvas as any)
const { handlePointer } = useCanvasInteractions()
// Create mock pointer event with middle button
const mockEvent = {
buttons: 4, // Middle mouse button
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} satisfies Partial<PointerEvent>
// Test
handlePointer(mockEvent as unknown as PointerEvent)
// Verify
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
})
it('should not prevent default when canvas is not in read_only mode and not middle button', () => {
// Setup
const mockCanvas = { read_only: false }
const { getCanvas } = useCanvasStore()
vi.mocked(getCanvas).mockReturnValue(mockCanvas as any)
const { handlePointer } = useCanvasInteractions()
// Create mock pointer event
const mockEvent = {
buttons: 1, // Left mouse button
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} satisfies Partial<PointerEvent>
// Test
handlePointer(mockEvent as unknown as PointerEvent)
// Verify - should not prevent default (let media handle normally)
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
})
it('should return early when canvas is null', () => {
// Setup
const { getCanvas } = useCanvasStore()
vi.mocked(getCanvas).mockReturnValue(null as any)
const { handlePointer } = useCanvasInteractions()
// Create mock pointer event that would normally trigger forwarding
const mockEvent = {
buttons: 1, // Left mouse button - would trigger space+drag if canvas had read_only=true
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} satisfies Partial<PointerEvent>
// Test
handlePointer(mockEvent as unknown as PointerEvent)
// Verify early return - no event methods should be called at all
expect(getCanvas).toHaveBeenCalled()
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
})
})
describe('handleWheel', () => {
it('should check navigation mode from settings', () => {
mockSettingStore.get.mockReturnValue('standard')
it('should forward ctrl+wheel events to canvas in standard nav mode', () => {
// Setup
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('standard')
const wheelEvent = new WheelEvent('wheel', {
const { handleWheel } = useCanvasInteractions()
// Create mock wheel event with ctrl key
const mockEvent = {
ctrlKey: true,
deltaY: -100
})
metaKey: false,
preventDefault: vi.fn()
} satisfies Partial<WheelEvent>
canvasInteractions.handleWheel(wheelEvent)
// Test
handleWheel(mockEvent as unknown as WheelEvent)
expect(mockSettingStore.get).toHaveBeenCalledWith(
'Comfy.Canvas.NavigationMode'
)
// Verify
expect(mockEvent.preventDefault).toHaveBeenCalled()
})
it('should not forward regular wheel events in standard mode', () => {
mockSettingStore.get.mockReturnValue('standard')
it('should forward all wheel events to canvas in legacy nav mode', () => {
// Setup
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('legacy')
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100
})
const { handleWheel } = useCanvasInteractions()
canvasInteractions.handleWheel(wheelEvent)
// Create mock wheel event without modifiers
const mockEvent = {
ctrlKey: false,
metaKey: false,
preventDefault: vi.fn()
} satisfies Partial<WheelEvent>
expect(mockCanvas.dispatchEvent).not.toHaveBeenCalled()
// Test
handleWheel(mockEvent as unknown as WheelEvent)
// Verify
expect(mockEvent.preventDefault).toHaveBeenCalled()
})
it('should forward all wheel events to canvas in legacy mode', () => {
mockSettingStore.get.mockReturnValue('legacy')
it('should not prevent default for regular wheel events in standard nav mode', () => {
// Setup
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('standard')
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100,
cancelable: true
})
const { handleWheel } = useCanvasInteractions()
canvasInteractions.handleWheel(wheelEvent)
// Create mock wheel event without modifiers
const mockEvent = {
ctrlKey: false,
metaKey: false,
preventDefault: vi.fn()
} satisfies Partial<WheelEvent>
expect(mockCanvas.dispatchEvent).toHaveBeenCalled()
})
// Test
handleWheel(mockEvent as unknown as WheelEvent)
it('should handle missing canvas gracefully', () => {
;(app.canvas as any).canvas = null
mockSettingStore.get.mockReturnValue('standard')
const wheelEvent = new WheelEvent('wheel', {
ctrlKey: true,
deltaY: -100
})
expect(() => {
canvasInteractions.handleWheel(wheelEvent)
}).not.toThrow()
})
})
describe('forwardEventToCanvas', () => {
it('should dispatch event to canvas element', () => {
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100,
ctrlKey: true
})
canvasInteractions.forwardEventToCanvas(wheelEvent)
expect(mockCanvas.dispatchEvent).toHaveBeenCalledWith(
expect.any(WheelEvent)
)
})
it('should handle missing canvas gracefully', () => {
;(app.canvas as any).canvas = null
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100
})
expect(() => {
canvasInteractions.forwardEventToCanvas(wheelEvent)
}).not.toThrow()
// Verify - should not prevent default (let component handle normally)
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,240 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useCanvasTransformSync } from '@/composables/graph/useCanvasTransformSync'
import type { LGraphCanvas } from '../../../../src/lib/litegraph/src/litegraph'
// Mock LiteGraph canvas
const createMockCanvas = (): Partial<LGraphCanvas> => ({
canvas: document.createElement('canvas'),
ds: {
offset: [0, 0],
scale: 1
} as any // Mock the DragAndScale type
})
describe('useCanvasTransformSync', () => {
let mockCanvas: LGraphCanvas
let syncFn: ReturnType<typeof vi.fn>
let callbacks: {
onStart: ReturnType<typeof vi.fn>
onUpdate: ReturnType<typeof vi.fn>
onStop: ReturnType<typeof vi.fn>
}
beforeEach(() => {
vi.useFakeTimers()
mockCanvas = createMockCanvas() as LGraphCanvas
syncFn = vi.fn()
callbacks = {
onStart: vi.fn(),
onUpdate: vi.fn(),
onStop: vi.fn()
}
// Mock requestAnimationFrame
global.requestAnimationFrame = vi.fn((cb) => {
setTimeout(cb, 16) // Simulate 60fps
return 1
})
global.cancelAnimationFrame = vi.fn()
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('should auto-start sync when canvas is provided', async () => {
const { isActive } = useCanvasTransformSync(mockCanvas, syncFn, callbacks)
await nextTick()
expect(isActive.value).toBe(true)
expect(callbacks.onStart).toHaveBeenCalledOnce()
expect(syncFn).toHaveBeenCalledWith(mockCanvas)
})
it('should not auto-start when autoStart is false', async () => {
const { isActive } = useCanvasTransformSync(mockCanvas, syncFn, callbacks, {
autoStart: false
})
await nextTick()
expect(isActive.value).toBe(false)
expect(callbacks.onStart).not.toHaveBeenCalled()
expect(syncFn).not.toHaveBeenCalled()
})
it('should not start when canvas is null', async () => {
const { isActive } = useCanvasTransformSync(null, syncFn, callbacks)
await nextTick()
expect(isActive.value).toBe(false)
expect(callbacks.onStart).not.toHaveBeenCalled()
})
it('should manually start and stop sync', async () => {
const { isActive, startSync, stopSync } = useCanvasTransformSync(
mockCanvas,
syncFn,
callbacks,
{ autoStart: false }
)
// Start manually
startSync()
await nextTick()
expect(isActive.value).toBe(true)
expect(callbacks.onStart).toHaveBeenCalledOnce()
// Stop manually
stopSync()
await nextTick()
expect(isActive.value).toBe(false)
expect(callbacks.onStop).toHaveBeenCalledOnce()
})
it('should call sync function on each frame', async () => {
useCanvasTransformSync(mockCanvas, syncFn, callbacks)
await nextTick()
// Advance timers to trigger additional frames (initial call + 3 more = 4 total)
vi.advanceTimersByTime(48) // 3 additional frames at 16ms each
await nextTick()
expect(syncFn).toHaveBeenCalledTimes(4) // Initial call + 3 timed calls
expect(syncFn).toHaveBeenCalledWith(mockCanvas)
})
it('should provide timing information in onUpdate callback', async () => {
// Mock performance.now to return predictable values
const mockNow = vi.spyOn(performance, 'now')
mockNow.mockReturnValueOnce(0).mockReturnValueOnce(5) // 5ms duration
useCanvasTransformSync(mockCanvas, syncFn, callbacks)
await nextTick()
expect(callbacks.onUpdate).toHaveBeenCalledWith(5)
})
it('should handle sync function that throws errors', async () => {
const errorSyncFn = vi.fn().mockImplementation(() => {
throw new Error('Sync failed')
})
// Creating the composable should not throw
expect(() => {
useCanvasTransformSync(mockCanvas, errorSyncFn, callbacks)
}).not.toThrow()
await nextTick()
// Even though sync function throws, the composable should handle it gracefully
expect(errorSyncFn).toHaveBeenCalled()
expect(callbacks.onStart).toHaveBeenCalled()
})
it('should not start if already active', async () => {
const { startSync } = useCanvasTransformSync(mockCanvas, syncFn, callbacks)
await nextTick()
// Try to start again
startSync()
await nextTick()
// Should only be called once from auto-start
expect(callbacks.onStart).toHaveBeenCalledOnce()
})
it('should not stop if already inactive', async () => {
const { stopSync } = useCanvasTransformSync(mockCanvas, syncFn, callbacks, {
autoStart: false
})
// Try to stop when not started
stopSync()
await nextTick()
expect(callbacks.onStop).not.toHaveBeenCalled()
})
it('should clean up on component unmount', async () => {
const TestComponent = {
setup() {
const { isActive } = useCanvasTransformSync(
mockCanvas,
syncFn,
callbacks
)
return { isActive }
},
template: '<div>{{ isActive }}</div>'
}
const wrapper = mount(TestComponent)
await nextTick()
expect(callbacks.onStart).toHaveBeenCalled()
// Unmount component
wrapper.unmount()
await nextTick()
expect(callbacks.onStop).toHaveBeenCalled()
expect(global.cancelAnimationFrame).toHaveBeenCalled()
})
it('should work without callbacks', async () => {
const { isActive } = useCanvasTransformSync(mockCanvas, syncFn)
await nextTick()
expect(isActive.value).toBe(true)
expect(syncFn).toHaveBeenCalledWith(mockCanvas)
})
it('should stop sync when canvas becomes null during sync', async () => {
let currentCanvas: any = mockCanvas
const dynamicSyncFn = vi.fn(() => {
// Simulate canvas becoming null during sync
currentCanvas = null
})
const { isActive } = useCanvasTransformSync(
currentCanvas,
dynamicSyncFn,
callbacks
)
await nextTick()
expect(isActive.value).toBe(true)
// Advance time to trigger sync
vi.advanceTimersByTime(16)
await nextTick()
// Should handle null canvas gracefully
expect(dynamicSyncFn).toHaveBeenCalled()
})
it('should use cancelAnimationFrame when stopping', async () => {
const { stopSync } = useCanvasTransformSync(mockCanvas, syncFn, callbacks)
await nextTick()
stopSync()
expect(global.cancelAnimationFrame).toHaveBeenCalledWith(1)
})
})

View File

@@ -0,0 +1,498 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSpatialIndex } from '@/composables/graph/useSpatialIndex'
// Mock @vueuse/core
vi.mock('@vueuse/core', () => ({
useDebounceFn: (fn: (...args: any[]) => any) => fn // Return function directly for testing
}))
describe('useSpatialIndex', () => {
let spatialIndex: ReturnType<typeof useSpatialIndex>
beforeEach(() => {
spatialIndex = useSpatialIndex()
})
describe('initialization', () => {
it('should start with null quadTree', () => {
expect(spatialIndex.quadTree.value).toBeNull()
})
it('should initialize with default bounds when first node is added', () => {
const { updateNode, quadTree, metrics } = spatialIndex
updateNode('node1', { x: 100, y: 100 }, { width: 200, height: 100 })
expect(quadTree.value).not.toBeNull()
expect(metrics.value.totalNodes).toBe(1)
})
it('should initialize with custom bounds', () => {
const { initialize, quadTree } = spatialIndex
const customBounds = { x: 0, y: 0, width: 5000, height: 3000 }
initialize(customBounds)
expect(quadTree.value).not.toBeNull()
})
it('should increment rebuild count on initialization', () => {
const { initialize, metrics } = spatialIndex
expect(metrics.value.rebuildCount).toBe(0)
initialize()
expect(metrics.value.rebuildCount).toBe(1)
})
it('should accept custom options', () => {
const customIndex = useSpatialIndex({
maxDepth: 8,
maxItemsPerNode: 6,
updateDebounceMs: 32
})
customIndex.initialize()
expect(customIndex.quadTree.value).not.toBeNull()
})
})
describe('updateNode', () => {
it('should add a new node to the index', () => {
const { updateNode, metrics } = spatialIndex
updateNode('node1', { x: 100, y: 100 }, { width: 200, height: 100 })
expect(metrics.value.totalNodes).toBe(1)
})
it('should update existing node position', () => {
const { updateNode, queryViewport } = spatialIndex
// Add node
updateNode('node1', { x: 100, y: 100 }, { width: 200, height: 100 })
// Move node
updateNode('node1', { x: 500, y: 500 }, { width: 200, height: 100 })
// Query old position - should not find node
const oldResults = queryViewport({
x: 50,
y: 50,
width: 300,
height: 200
})
expect(oldResults).not.toContain('node1')
// Query new position - should find node
const newResults = queryViewport({
x: 450,
y: 450,
width: 300,
height: 200
})
expect(newResults).toContain('node1')
})
it('should auto-initialize if quadTree is null', () => {
const { updateNode, quadTree } = spatialIndex
expect(quadTree.value).toBeNull()
updateNode('node1', { x: 0, y: 0 }, { width: 100, height: 100 })
expect(quadTree.value).not.toBeNull()
})
})
describe('batchUpdate', () => {
it('should update multiple nodes at once', () => {
const { batchUpdate, metrics } = spatialIndex
const updates = [
{
id: 'node1',
position: { x: 100, y: 100 },
size: { width: 200, height: 100 }
},
{
id: 'node2',
position: { x: 300, y: 300 },
size: { width: 150, height: 150 }
},
{
id: 'node3',
position: { x: 500, y: 200 },
size: { width: 100, height: 200 }
}
]
batchUpdate(updates)
expect(metrics.value.totalNodes).toBe(3)
})
it('should handle empty batch', () => {
const { batchUpdate, metrics } = spatialIndex
batchUpdate([])
expect(metrics.value.totalNodes).toBe(0)
})
it('should auto-initialize if needed', () => {
const { batchUpdate, quadTree } = spatialIndex
expect(quadTree.value).toBeNull()
batchUpdate([
{
id: 'node1',
position: { x: 0, y: 0 },
size: { width: 100, height: 100 }
}
])
expect(quadTree.value).not.toBeNull()
})
})
describe('removeNode', () => {
beforeEach(() => {
spatialIndex.updateNode(
'node1',
{ x: 100, y: 100 },
{ width: 200, height: 100 }
)
spatialIndex.updateNode(
'node2',
{ x: 300, y: 300 },
{ width: 200, height: 100 }
)
})
it('should remove node from index', () => {
const { removeNode, metrics } = spatialIndex
expect(metrics.value.totalNodes).toBe(2)
removeNode('node1')
expect(metrics.value.totalNodes).toBe(1)
})
it('should handle removing non-existent node', () => {
const { removeNode, metrics } = spatialIndex
expect(metrics.value.totalNodes).toBe(2)
removeNode('node999')
expect(metrics.value.totalNodes).toBe(2)
})
it('should handle removeNode when quadTree is null', () => {
const freshIndex = useSpatialIndex()
// Should not throw
expect(() => freshIndex.removeNode('node1')).not.toThrow()
})
})
describe('queryViewport', () => {
beforeEach(() => {
// Set up a grid of nodes
spatialIndex.updateNode(
'node1',
{ x: 0, y: 0 },
{ width: 100, height: 100 }
)
spatialIndex.updateNode(
'node2',
{ x: 200, y: 0 },
{ width: 100, height: 100 }
)
spatialIndex.updateNode(
'node3',
{ x: 0, y: 200 },
{ width: 100, height: 100 }
)
spatialIndex.updateNode(
'node4',
{ x: 200, y: 200 },
{ width: 100, height: 100 }
)
})
it('should find nodes within viewport bounds', () => {
const { queryViewport } = spatialIndex
// Query top-left quadrant
const results = queryViewport({ x: -50, y: -50, width: 200, height: 200 })
expect(results).toContain('node1')
expect(results).not.toContain('node2')
expect(results).not.toContain('node3')
expect(results).not.toContain('node4')
})
it('should find multiple nodes in larger viewport', () => {
const { queryViewport } = spatialIndex
// Query entire area
const results = queryViewport({ x: -50, y: -50, width: 400, height: 400 })
expect(results).toHaveLength(4)
expect(results).toContain('node1')
expect(results).toContain('node2')
expect(results).toContain('node3')
expect(results).toContain('node4')
})
it('should return empty array for empty region', () => {
const { queryViewport } = spatialIndex
const results = queryViewport({
x: 1000,
y: 1000,
width: 100,
height: 100
})
expect(results).toEqual([])
})
it('should update metrics after query', () => {
const { queryViewport, metrics } = spatialIndex
queryViewport({ x: 0, y: 0, width: 300, height: 300 })
expect(metrics.value.queryTime).toBeGreaterThan(0)
expect(metrics.value.visibleNodes).toBe(4)
})
it('should handle query when quadTree is null', () => {
const freshIndex = useSpatialIndex()
const results = freshIndex.queryViewport({
x: 0,
y: 0,
width: 100,
height: 100
})
expect(results).toEqual([])
})
})
describe('queryRadius', () => {
beforeEach(() => {
// Set up nodes at different distances
spatialIndex.updateNode(
'center',
{ x: 475, y: 475 },
{ width: 50, height: 50 }
)
spatialIndex.updateNode(
'near1',
{ x: 525, y: 475 },
{ width: 50, height: 50 }
)
spatialIndex.updateNode(
'near2',
{ x: 425, y: 475 },
{ width: 50, height: 50 }
)
spatialIndex.updateNode(
'far',
{ x: 775, y: 775 },
{ width: 50, height: 50 }
)
})
it('should find nodes within radius', () => {
const { queryRadius } = spatialIndex
const results = queryRadius({ x: 500, y: 500 }, 100)
expect(results).toContain('center')
expect(results).toContain('near1')
expect(results).toContain('near2')
expect(results).not.toContain('far')
})
it('should handle zero radius', () => {
const { queryRadius } = spatialIndex
const results = queryRadius({ x: 500, y: 500 }, 0)
// Zero radius creates a point query at (500,500)
// The 'center' node spans 475-525 on both axes, so it contains this point
expect(results).toContain('center')
})
it('should handle large radius', () => {
const { queryRadius } = spatialIndex
const results = queryRadius({ x: 500, y: 500 }, 1000)
expect(results).toHaveLength(4) // Should find all nodes
})
})
describe('clear', () => {
beforeEach(() => {
spatialIndex.updateNode(
'node1',
{ x: 100, y: 100 },
{ width: 200, height: 100 }
)
spatialIndex.updateNode(
'node2',
{ x: 300, y: 300 },
{ width: 200, height: 100 }
)
})
it('should remove all nodes', () => {
const { clear, metrics } = spatialIndex
expect(metrics.value.totalNodes).toBe(2)
clear()
expect(metrics.value.totalNodes).toBe(0)
})
it('should reset metrics', () => {
const { clear, queryViewport, metrics } = spatialIndex
// Do a query to set visible nodes
queryViewport({ x: 0, y: 0, width: 500, height: 500 })
expect(metrics.value.visibleNodes).toBe(2)
clear()
expect(metrics.value.visibleNodes).toBe(0)
})
it('should handle clear when quadTree is null', () => {
const freshIndex = useSpatialIndex()
expect(() => freshIndex.clear()).not.toThrow()
})
})
describe('rebuild', () => {
it('should rebuild index with new nodes', () => {
const { rebuild, metrics, queryViewport } = spatialIndex
// Add initial nodes
spatialIndex.updateNode(
'old1',
{ x: 0, y: 0 },
{ width: 100, height: 100 }
)
expect(metrics.value.rebuildCount).toBe(1)
// Rebuild with new set
const newNodes = new Map([
[
'new1',
{ position: { x: 100, y: 100 }, size: { width: 50, height: 50 } }
],
[
'new2',
{ position: { x: 200, y: 200 }, size: { width: 50, height: 50 } }
]
])
rebuild(newNodes)
expect(metrics.value.totalNodes).toBe(2)
expect(metrics.value.rebuildCount).toBe(2)
// Old nodes should be gone
const oldResults = queryViewport({
x: -50,
y: -50,
width: 100,
height: 100
})
expect(oldResults).not.toContain('old1')
// New nodes should be findable
const newResults = queryViewport({
x: 50,
y: 50,
width: 200,
height: 200
})
expect(newResults).toContain('new1')
expect(newResults).toContain('new2')
})
it('should handle empty rebuild', () => {
const { rebuild, metrics } = spatialIndex
rebuild(new Map())
expect(metrics.value.totalNodes).toBe(0)
})
})
describe('metrics', () => {
it('should track performance metrics', () => {
const { metrics, updateNode, queryViewport } = spatialIndex
// Initial state
expect(metrics.value).toEqual({
queryTime: 0,
totalNodes: 0,
visibleNodes: 0,
treeDepth: 0,
rebuildCount: 0
})
// Add nodes
updateNode('node1', { x: 0, y: 0 }, { width: 100, height: 100 })
expect(metrics.value.totalNodes).toBe(1)
// Query
queryViewport({ x: -50, y: -50, width: 200, height: 200 })
expect(metrics.value.queryTime).toBeGreaterThan(0)
expect(metrics.value.visibleNodes).toBe(1)
})
})
describe('edge cases', () => {
it('should handle nodes with zero size', () => {
const { updateNode, queryViewport } = spatialIndex
updateNode('point', { x: 100, y: 100 }, { width: 0, height: 0 })
// Should still be findable
const results = queryViewport({ x: 50, y: 50, width: 100, height: 100 })
expect(results).toContain('point')
})
it('should handle negative positions', () => {
const { updateNode, queryViewport } = spatialIndex
updateNode('negative', { x: -500, y: -500 }, { width: 100, height: 100 })
const results = queryViewport({
x: -600,
y: -600,
width: 200,
height: 200
})
expect(results).toContain('negative')
})
it('should handle very large nodes', () => {
const { updateNode, queryViewport } = spatialIndex
updateNode('huge', { x: 0, y: 0 }, { width: 5000, height: 5000 })
// Should be found even when querying small area within it
const results = queryViewport({ x: 100, y: 100, width: 10, height: 10 })
expect(results).toContain('huge')
})
})
describe('debouncedUpdateNode', () => {
it('should be available', () => {
const { debouncedUpdateNode } = spatialIndex
expect(debouncedUpdateNode).toBeDefined()
expect(typeof debouncedUpdateNode).toBe('function')
})
})
})

View File

@@ -0,0 +1,277 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useTransformSettling } from '@/composables/graph/useTransformSettling'
describe('useTransformSettling', () => {
let element: HTMLDivElement
beforeEach(() => {
vi.useFakeTimers()
element = document.createElement('div')
document.body.appendChild(element)
})
afterEach(() => {
vi.useRealTimers()
document.body.removeChild(element)
})
it('should track wheel events and settle after delay', async () => {
const { isTransforming } = useTransformSettling(element)
// Initially not transforming
expect(isTransforming.value).toBe(false)
// Dispatch wheel event
element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
await nextTick()
// Should be transforming
expect(isTransforming.value).toBe(true)
// Advance time but not past settle delay
vi.advanceTimersByTime(100)
expect(isTransforming.value).toBe(true)
// Advance past settle delay (default 200ms)
vi.advanceTimersByTime(150)
expect(isTransforming.value).toBe(false)
})
it('should reset settle timer on subsequent wheel events', async () => {
const { isTransforming } = useTransformSettling(element, {
settleDelay: 300
})
// First wheel event
element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
await nextTick()
expect(isTransforming.value).toBe(true)
// Advance time partially
vi.advanceTimersByTime(200)
expect(isTransforming.value).toBe(true)
// Another wheel event should reset the timer
element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
await nextTick()
// Advance 200ms more - should still be transforming
vi.advanceTimersByTime(200)
expect(isTransforming.value).toBe(true)
// Need another 100ms to settle (300ms total from last event)
vi.advanceTimersByTime(100)
expect(isTransforming.value).toBe(false)
})
it('should track pan events when trackPan is enabled', async () => {
const { isTransforming } = useTransformSettling(element, {
trackPan: true,
settleDelay: 200
})
// Pointer down should start transform
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
await nextTick()
expect(isTransforming.value).toBe(true)
// Pointer move should keep it active
vi.advanceTimersByTime(100)
element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
await nextTick()
// Should still be transforming
expect(isTransforming.value).toBe(true)
// Pointer up
element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
await nextTick()
// Should still be transforming until settle delay
expect(isTransforming.value).toBe(true)
// Advance past settle delay
vi.advanceTimersByTime(200)
expect(isTransforming.value).toBe(false)
})
it('should not track pan events when trackPan is disabled', async () => {
const { isTransforming } = useTransformSettling(element, {
trackPan: false
})
// Pointer events should not trigger transform
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
await nextTick()
expect(isTransforming.value).toBe(false)
})
it('should handle pointer cancel events', async () => {
const { isTransforming } = useTransformSettling(element, {
trackPan: true,
settleDelay: 200
})
// Start panning
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
await nextTick()
expect(isTransforming.value).toBe(true)
// Cancel instead of up
element.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true }))
await nextTick()
// Should still settle normally
vi.advanceTimersByTime(200)
expect(isTransforming.value).toBe(false)
})
it('should work with ref target', async () => {
const targetRef = ref<HTMLElement | null>(null)
const { isTransforming } = useTransformSettling(targetRef)
// No target yet
expect(isTransforming.value).toBe(false)
// Set target
targetRef.value = element
await nextTick()
// Now events should work
element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
await nextTick()
expect(isTransforming.value).toBe(true)
vi.advanceTimersByTime(200)
expect(isTransforming.value).toBe(false)
})
it('should use capture phase for events', async () => {
const captureHandler = vi.fn()
const bubbleHandler = vi.fn()
// Add handlers to verify capture phase
element.addEventListener('wheel', captureHandler, true)
element.addEventListener('wheel', bubbleHandler, false)
const { isTransforming } = useTransformSettling(element)
// Create child element
const child = document.createElement('div')
element.appendChild(child)
// Dispatch event on child
child.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
await nextTick()
// Capture handler should be called before bubble handler
expect(captureHandler).toHaveBeenCalled()
expect(isTransforming.value).toBe(true)
element.removeEventListener('wheel', captureHandler, true)
element.removeEventListener('wheel', bubbleHandler, false)
})
it('should throttle pointer move events', async () => {
const { isTransforming } = useTransformSettling(element, {
trackPan: true,
pointerMoveThrottle: 50,
settleDelay: 100
})
// Start panning
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
await nextTick()
// Fire many pointer move events rapidly
for (let i = 0; i < 10; i++) {
element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
vi.advanceTimersByTime(5) // 5ms between events
}
await nextTick()
// Should still be transforming
expect(isTransforming.value).toBe(true)
// End panning
element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
// Advance past settle delay
vi.advanceTimersByTime(100)
expect(isTransforming.value).toBe(false)
})
it('should clean up event listeners when component unmounts', async () => {
const removeEventListenerSpy = vi.spyOn(element, 'removeEventListener')
// Create a test component
const TestComponent = {
setup() {
const { isTransforming } = useTransformSettling(element, {
trackPan: true
})
return { isTransforming }
},
template: '<div>{{ isTransforming }}</div>'
}
const wrapper = mount(TestComponent)
await nextTick()
// Unmount component
wrapper.unmount()
// Should have removed all event listeners
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'wheel',
expect.any(Function),
expect.objectContaining({ capture: true })
)
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'pointerdown',
expect.any(Function),
expect.objectContaining({ capture: true })
)
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'pointermove',
expect.any(Function),
expect.objectContaining({ capture: true })
)
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'pointerup',
expect.any(Function),
expect.objectContaining({ capture: true })
)
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'pointercancel',
expect.any(Function),
expect.objectContaining({ capture: true })
)
})
it('should use passive listeners when specified', async () => {
const addEventListenerSpy = vi.spyOn(element, 'addEventListener')
useTransformSettling(element, {
passive: true,
trackPan: true
})
// Check that passive option was used for appropriate events
expect(addEventListenerSpy).toHaveBeenCalledWith(
'wheel',
expect.any(Function),
expect.objectContaining({ passive: true, capture: true })
)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'pointermove',
expect.any(Function),
expect.objectContaining({ passive: true, capture: true })
)
})
})

View File

@@ -0,0 +1,503 @@
import {
type MockedFunction,
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest'
import { ref } from 'vue'
import {
useBooleanWidgetValue,
useNumberWidgetValue,
useStringWidgetValue,
useWidgetValue
} from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
describe('useWidgetValue', () => {
let mockWidget: SimplifiedWidget<string>
let mockEmit: MockedFunction<(event: 'update:modelValue', value: any) => void>
let consoleWarnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
mockWidget = {
name: 'testWidget',
type: 'string',
value: 'initial',
callback: vi.fn()
}
mockEmit = vi.fn()
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
consoleWarnSpy.mockRestore()
})
describe('basic functionality', () => {
it('should initialize with modelValue', () => {
const { localValue } = useWidgetValue({
widget: mockWidget,
modelValue: 'test value',
defaultValue: '',
emit: mockEmit
})
expect(localValue.value).toBe('test value')
})
it('should use defaultValue when modelValue is null', () => {
const { localValue } = useWidgetValue({
widget: mockWidget,
modelValue: null as any,
defaultValue: 'default',
emit: mockEmit
})
expect(localValue.value).toBe('default')
})
it('should use defaultValue when modelValue is undefined', () => {
const { localValue } = useWidgetValue({
widget: mockWidget,
modelValue: undefined as any,
defaultValue: 'default',
emit: mockEmit
})
expect(localValue.value).toBe('default')
})
})
describe('onChange handler', () => {
it('should update localValue immediately', () => {
const { localValue, onChange } = useWidgetValue({
widget: mockWidget,
modelValue: 'initial',
defaultValue: '',
emit: mockEmit
})
onChange('new value')
expect(localValue.value).toBe('new value')
})
it('should emit update:modelValue event', () => {
const { onChange } = useWidgetValue({
widget: mockWidget,
modelValue: 'initial',
defaultValue: '',
emit: mockEmit
})
onChange('new value')
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'new value')
})
// useGraphNodeMaanger's createWrappedWidgetCallback makes the callback right now instead of useWidgetValue
// it('should call widget callback if it exists', () => {
// const { onChange } = useWidgetValue({
// widget: mockWidget,
// modelValue: 'initial',
// defaultValue: '',
// emit: mockEmit
// })
// onChange('new value')
// expect(mockWidget.callback).toHaveBeenCalledWith('new value')
// })
it('should not error if widget callback is undefined', () => {
const widgetWithoutCallback = { ...mockWidget, callback: undefined }
const { onChange } = useWidgetValue({
widget: widgetWithoutCallback,
modelValue: 'initial',
defaultValue: '',
emit: mockEmit
})
expect(() => onChange('new value')).not.toThrow()
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'new value')
})
it('should handle null values', () => {
const { localValue, onChange } = useWidgetValue({
widget: mockWidget,
modelValue: 'initial',
defaultValue: 'default',
emit: mockEmit
})
onChange(null as any)
expect(localValue.value).toBe('default')
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'default')
})
it('should handle undefined values', () => {
const { localValue, onChange } = useWidgetValue({
widget: mockWidget,
modelValue: 'initial',
defaultValue: 'default',
emit: mockEmit
})
onChange(undefined as any)
expect(localValue.value).toBe('default')
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'default')
})
})
describe('type safety', () => {
it('should handle type mismatches with warning', () => {
const numberWidget: SimplifiedWidget<number> = {
name: 'numberWidget',
type: 'number',
value: 42,
callback: vi.fn()
}
const { onChange } = useWidgetValue({
widget: numberWidget,
modelValue: 10,
defaultValue: 0,
emit: mockEmit
})
// Pass string to number widget
onChange('not a number' as any)
expect(consoleWarnSpy).toHaveBeenCalledWith(
'useWidgetValue: Type mismatch for widget numberWidget. Expected number, got string'
)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 0) // Uses defaultValue
})
it('should accept values of matching type', () => {
const numberWidget: SimplifiedWidget<number> = {
name: 'numberWidget',
type: 'number',
value: 42,
callback: vi.fn()
}
const { onChange } = useWidgetValue({
widget: numberWidget,
modelValue: 10,
defaultValue: 0,
emit: mockEmit
})
onChange(25)
expect(consoleWarnSpy).not.toHaveBeenCalled()
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 25)
})
})
describe('transform function', () => {
it('should apply transform function to new values', () => {
const transform = vi.fn((value: string) => value.toUpperCase())
const { onChange } = useWidgetValue({
widget: mockWidget,
modelValue: 'initial',
defaultValue: '',
emit: mockEmit,
transform
})
onChange('hello')
expect(transform).toHaveBeenCalledWith('hello')
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'HELLO')
})
it('should skip type checking when transform is provided', () => {
const numberWidget: SimplifiedWidget<number> = {
name: 'numberWidget',
type: 'number',
value: 42,
callback: vi.fn()
}
const transform = (value: string) => parseInt(value, 10) || 0
const { onChange } = useWidgetValue({
widget: numberWidget,
modelValue: 10,
defaultValue: 0,
emit: mockEmit,
transform
})
onChange('123')
expect(consoleWarnSpy).not.toHaveBeenCalled()
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 123)
})
})
describe('external updates', () => {
it('should update localValue when modelValue changes', async () => {
const modelValue = ref('initial')
const { localValue } = useWidgetValue({
widget: mockWidget,
modelValue: modelValue.value,
defaultValue: '',
emit: mockEmit
})
expect(localValue.value).toBe('initial')
// Simulate parent updating modelValue
modelValue.value = 'updated externally'
// Re-create the composable with new value (simulating prop change)
const { localValue: newLocalValue } = useWidgetValue({
widget: mockWidget,
modelValue: modelValue.value,
defaultValue: '',
emit: mockEmit
})
expect(newLocalValue.value).toBe('updated externally')
})
it('should handle external null values', async () => {
const modelValue = ref<string | null>('initial')
const { localValue } = useWidgetValue({
widget: mockWidget,
modelValue: modelValue.value!,
defaultValue: 'default',
emit: mockEmit
})
expect(localValue.value).toBe('initial')
// Simulate external update to null
modelValue.value = null
const { localValue: newLocalValue } = useWidgetValue({
widget: mockWidget,
modelValue: modelValue.value as any,
defaultValue: 'default',
emit: mockEmit
})
expect(newLocalValue.value).toBe('default')
})
})
describe('useStringWidgetValue helper', () => {
it('should handle string values correctly', () => {
const stringWidget: SimplifiedWidget<string> = {
name: 'textWidget',
type: 'string',
value: 'hello',
callback: vi.fn()
}
const { localValue, onChange } = useStringWidgetValue(
stringWidget,
'initial',
mockEmit
)
expect(localValue.value).toBe('initial')
onChange('new string')
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'new string')
})
it('should transform undefined to empty string', () => {
const stringWidget: SimplifiedWidget<string> = {
name: 'textWidget',
type: 'string',
value: '',
callback: vi.fn()
}
const { onChange } = useStringWidgetValue(stringWidget, '', mockEmit)
onChange(undefined as any)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', '')
})
it('should convert non-string values to string', () => {
const stringWidget: SimplifiedWidget<string> = {
name: 'textWidget',
type: 'string',
value: '',
callback: vi.fn()
}
const { onChange } = useStringWidgetValue(stringWidget, '', mockEmit)
onChange(123 as any)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', '123')
})
})
describe('useNumberWidgetValue helper', () => {
it('should handle number values correctly', () => {
const numberWidget: SimplifiedWidget<number> = {
name: 'sliderWidget',
type: 'number',
value: 50,
callback: vi.fn()
}
const { localValue, onChange } = useNumberWidgetValue(
numberWidget,
25,
mockEmit
)
expect(localValue.value).toBe(25)
onChange(75)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 75)
})
it('should handle array values from PrimeVue Slider', () => {
const numberWidget: SimplifiedWidget<number> = {
name: 'sliderWidget',
type: 'number',
value: 50,
callback: vi.fn()
}
const { onChange } = useNumberWidgetValue(numberWidget, 25, mockEmit)
// PrimeVue Slider can emit number[]
onChange([42, 100] as any)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 42)
})
it('should handle empty array', () => {
const numberWidget: SimplifiedWidget<number> = {
name: 'sliderWidget',
type: 'number',
value: 50,
callback: vi.fn()
}
const { onChange } = useNumberWidgetValue(numberWidget, 25, mockEmit)
onChange([] as any)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 0)
})
it('should convert string numbers', () => {
const numberWidget: SimplifiedWidget<number> = {
name: 'numberWidget',
type: 'number',
value: 0,
callback: vi.fn()
}
const { onChange } = useNumberWidgetValue(numberWidget, 0, mockEmit)
onChange('42' as any)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 42)
})
it('should handle invalid number conversions', () => {
const numberWidget: SimplifiedWidget<number> = {
name: 'numberWidget',
type: 'number',
value: 0,
callback: vi.fn()
}
const { onChange } = useNumberWidgetValue(numberWidget, 0, mockEmit)
onChange('not-a-number' as any)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 0)
})
})
describe('useBooleanWidgetValue helper', () => {
it('should handle boolean values correctly', () => {
const boolWidget: SimplifiedWidget<boolean> = {
name: 'toggleWidget',
type: 'boolean',
value: false,
callback: vi.fn()
}
const { localValue, onChange } = useBooleanWidgetValue(
boolWidget,
true,
mockEmit
)
expect(localValue.value).toBe(true)
onChange(false)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', false)
})
it('should convert truthy values to true', () => {
const boolWidget: SimplifiedWidget<boolean> = {
name: 'toggleWidget',
type: 'boolean',
value: false,
callback: vi.fn()
}
const { onChange } = useBooleanWidgetValue(boolWidget, false, mockEmit)
onChange('truthy' as any)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', true)
})
it('should convert falsy values to false', () => {
const boolWidget: SimplifiedWidget<boolean> = {
name: 'toggleWidget',
type: 'boolean',
value: false,
callback: vi.fn()
}
const { onChange } = useBooleanWidgetValue(boolWidget, true, mockEmit)
onChange(0 as any)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', false)
})
})
describe('edge cases', () => {
it('should handle rapid onChange calls', () => {
const { onChange } = useWidgetValue({
widget: mockWidget,
modelValue: 'initial',
defaultValue: '',
emit: mockEmit
})
onChange('value1')
onChange('value2')
onChange('value3')
expect(mockEmit).toHaveBeenCalledTimes(3)
expect(mockEmit).toHaveBeenNthCalledWith(1, 'update:modelValue', 'value1')
expect(mockEmit).toHaveBeenNthCalledWith(2, 'update:modelValue', 'value2')
expect(mockEmit).toHaveBeenNthCalledWith(3, 'update:modelValue', 'value3')
})
it('should handle widget with all properties undefined', () => {
const minimalWidget = {
name: 'minimal',
type: 'unknown'
} as SimplifiedWidget<any>
const { localValue, onChange } = useWidgetValue({
widget: minimalWidget,
modelValue: 'test',
defaultValue: 'default',
emit: mockEmit
})
expect(localValue.value).toBe('test')
expect(() => onChange('new')).not.toThrow()
})
})
})

View File

@@ -8,7 +8,12 @@ import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
function createMockNode(
nodeTypeName: string,
widgets: Array<{ name: string; value: any }> = [],
isApiNode = true
isApiNode = true,
inputs: Array<{
name: string
connected?: boolean
useLinksArray?: boolean
}> = []
): LGraphNode {
const mockWidgets = widgets.map(({ name, value }) => ({
name,
@@ -16,7 +21,16 @@ function createMockNode(
type: 'combo'
})) as IComboWidget[]
return {
const mockInputs =
inputs.length > 0
? inputs.map(({ name, connected, useLinksArray }) =>
useLinksArray
? { name, links: connected ? [1] : [] }
: { name, link: connected ? 1 : null }
)
: undefined
const node: any = {
id: Math.random().toString(),
widgets: mockWidgets,
constructor: {
@@ -25,7 +39,24 @@ function createMockNode(
api_node: isApiNode
}
}
} as unknown as LGraphNode
}
if (mockInputs) {
node.inputs = mockInputs
// Provide the common helpers some frontend code may call
node.findInputSlot = function (portName: string) {
return this.inputs?.findIndex((i: any) => i.name === portName) ?? -1
}
node.isInputConnected = function (idx: number) {
const port = this.inputs?.[idx]
if (!port) return false
if (typeof port.link !== 'undefined') return port.link != null
if (Array.isArray(port.links)) return port.links.length > 0
return false
}
}
return node as LGraphNode
}
describe('useNodePricing', () => {
@@ -363,34 +394,51 @@ describe('useNodePricing', () => {
})
describe('dynamic pricing - IdeogramV3', () => {
it('should return $0.09 for Quality rendering speed', () => {
it('should return correct prices for IdeogramV3 node', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('IdeogramV3', [
{ name: 'rendering_speed', value: 'Quality' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.09/Run')
})
const testCases = [
{
rendering_speed: 'Quality',
character_image: false,
expected: '$0.09/Run'
},
{
rendering_speed: 'Quality',
character_image: true,
expected: '$0.20/Run'
},
{
rendering_speed: 'Default',
character_image: false,
expected: '$0.06/Run'
},
{
rendering_speed: 'Default',
character_image: true,
expected: '$0.15/Run'
},
{
rendering_speed: 'Turbo',
character_image: false,
expected: '$0.03/Run'
},
{
rendering_speed: 'Turbo',
character_image: true,
expected: '$0.10/Run'
}
]
it('should return $0.06 for Balanced rendering speed', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('IdeogramV3', [
{ name: 'rendering_speed', value: 'Balanced' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.06/Run')
})
it('should return $0.03 for Turbo rendering speed', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('IdeogramV3', [
{ name: 'rendering_speed', value: 'Turbo' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.03/Run')
testCases.forEach(({ rendering_speed, character_image, expected }) => {
const node = createMockNode(
'IdeogramV3',
[{ name: 'rendering_speed', value: rendering_speed }],
true,
[{ name: 'character_image', connected: character_image }]
)
expect(getNodeDisplayPrice(node)).toBe(expected)
})
})
it('should return range when rendering_speed widget is missing', () => {
@@ -935,7 +983,11 @@ describe('useNodePricing', () => {
const { getRelevantWidgetNames } = useNodePricing()
const widgetNames = getRelevantWidgetNames('IdeogramV3')
expect(widgetNames).toEqual(['rendering_speed', 'num_images'])
expect(widgetNames).toEqual([
'rendering_speed',
'num_images',
'character_image'
])
})
})
@@ -1728,4 +1780,86 @@ describe('useNodePricing', () => {
})
})
})
describe('dynamic pricing - ByteDance Seedance video nodes', () => {
it('should return base 10s range for PRO 1080p on ByteDanceTextToVideoNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('ByteDanceTextToVideoNode', [
{ name: 'model', value: 'seedance-1-0-pro' },
{ name: 'duration', value: '10' },
{ name: 'resolution', value: '1080p' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$1.18-$1.22/Run')
})
it('should scale to half for 5s PRO 1080p on ByteDanceTextToVideoNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('ByteDanceTextToVideoNode', [
{ name: 'model', value: 'seedance-1-0-pro' },
{ name: 'duration', value: '5' },
{ name: 'resolution', value: '1080p' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.59-$0.61/Run')
})
it('should scale for 8s PRO 480p on ByteDanceImageToVideoNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('ByteDanceImageToVideoNode', [
{ name: 'model', value: 'seedance-1-0-pro' },
{ name: 'duration', value: '8' },
{ name: 'resolution', value: '480p' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.18-$0.19/Run')
})
it('should scale correctly for 12s PRO 720p on ByteDanceFirstLastFrameNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('ByteDanceFirstLastFrameNode', [
{ name: 'model', value: 'seedance-1-0-pro' },
{ name: 'duration', value: '12' },
{ name: 'resolution', value: '720p' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.61-$0.67/Run')
})
it('should collapse to a single value when min and max round equal for LITE 480p 3s on ByteDanceImageReferenceNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('ByteDanceImageReferenceNode', [
{ name: 'model', value: 'seedance-1-0-lite' },
{ name: 'duration', value: '3' },
{ name: 'resolution', value: '480p' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.05/Run') // 0.17..0.18 scaled by 0.3 both round to 0.05
})
it('should return Token-based when required widgets are missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const missingModel = createMockNode('ByteDanceFirstLastFrameNode', [
{ name: 'duration', value: '10' },
{ name: 'resolution', value: '1080p' }
])
const missingResolution = createMockNode('ByteDanceImageToVideoNode', [
{ name: 'model', value: 'seedance-1-0-pro' },
{ name: 'duration', value: '10' }
])
const missingDuration = createMockNode('ByteDanceTextToVideoNode', [
{ name: 'model', value: 'seedance-1-0-lite' },
{ name: 'resolution', value: '720p' }
])
expect(getNodeDisplayPrice(missingModel)).toBe('Token-based')
expect(getNodeDisplayPrice(missingResolution)).toBe('Token-based')
expect(getNodeDisplayPrice(missingDuration)).toBe('Token-based')
})
})
})

View File

@@ -0,0 +1,378 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { usePacksSelection } from '@/composables/nodePack/usePacksSelection'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual('vue-i18n')
return {
...actual,
useI18n: () => ({
t: vi.fn((key) => key)
})
}
})
type NodePack = components['schemas']['Node']
describe('usePacksSelection', () => {
let managerStore: ReturnType<typeof useComfyManagerStore>
let mockIsPackInstalled: ReturnType<typeof vi.fn>
const createMockPack = (id: string): NodePack => ({
id,
name: `Pack ${id}`,
description: `Description for pack ${id}`,
category: 'Nodes',
author: 'Test Author',
license: 'MIT',
repository: 'https://github.com/test/pack',
tags: [],
status: 'NodeStatusActive'
})
beforeEach(() => {
vi.clearAllMocks()
const pinia = createPinia()
setActivePinia(pinia)
managerStore = useComfyManagerStore()
// Mock the isPackInstalled method
mockIsPackInstalled = vi.fn()
managerStore.isPackInstalled = mockIsPackInstalled
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('installedPacks', () => {
it('should filter and return only installed packs', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2'),
createMockPack('pack3')
])
mockIsPackInstalled.mockImplementation((id: string) => {
return id === 'pack1' || id === 'pack3'
})
const { installedPacks } = usePacksSelection(nodePacks)
expect(installedPacks.value).toHaveLength(2)
expect(installedPacks.value[0].id).toBe('pack1')
expect(installedPacks.value[1].id).toBe('pack3')
expect(mockIsPackInstalled).toHaveBeenCalledTimes(3)
})
it('should return empty array when no packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
mockIsPackInstalled.mockReturnValue(false)
const { installedPacks } = usePacksSelection(nodePacks)
expect(installedPacks.value).toHaveLength(0)
})
it('should update when nodePacks ref changes', () => {
const nodePacks = ref<NodePack[]>([createMockPack('pack1')])
mockIsPackInstalled.mockReturnValue(true)
const { installedPacks } = usePacksSelection(nodePacks)
expect(installedPacks.value).toHaveLength(1)
// Add more packs
nodePacks.value = [
createMockPack('pack1'),
createMockPack('pack2'),
createMockPack('pack3')
]
expect(installedPacks.value).toHaveLength(3)
})
})
describe('notInstalledPacks', () => {
it('should filter and return only not installed packs', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2'),
createMockPack('pack3')
])
mockIsPackInstalled.mockImplementation((id: string) => {
return id === 'pack1'
})
const { notInstalledPacks } = usePacksSelection(nodePacks)
expect(notInstalledPacks.value).toHaveLength(2)
expect(notInstalledPacks.value[0].id).toBe('pack2')
expect(notInstalledPacks.value[1].id).toBe('pack3')
})
it('should return all packs when none are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
mockIsPackInstalled.mockReturnValue(false)
const { notInstalledPacks } = usePacksSelection(nodePacks)
expect(notInstalledPacks.value).toHaveLength(2)
})
})
describe('isAllInstalled', () => {
it('should return true when all packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
mockIsPackInstalled.mockReturnValue(true)
const { isAllInstalled } = usePacksSelection(nodePacks)
expect(isAllInstalled.value).toBe(true)
})
it('should return false when not all packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
mockIsPackInstalled.mockImplementation((id: string) => id === 'pack1')
const { isAllInstalled } = usePacksSelection(nodePacks)
expect(isAllInstalled.value).toBe(false)
})
it('should return true for empty array', () => {
const nodePacks = ref<NodePack[]>([])
const { isAllInstalled } = usePacksSelection(nodePacks)
expect(isAllInstalled.value).toBe(true)
})
})
describe('isNoneInstalled', () => {
it('should return true when no packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
mockIsPackInstalled.mockReturnValue(false)
const { isNoneInstalled } = usePacksSelection(nodePacks)
expect(isNoneInstalled.value).toBe(true)
})
it('should return false when some packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
mockIsPackInstalled.mockImplementation((id: string) => id === 'pack1')
const { isNoneInstalled } = usePacksSelection(nodePacks)
expect(isNoneInstalled.value).toBe(false)
})
it('should return true for empty array', () => {
const nodePacks = ref<NodePack[]>([])
const { isNoneInstalled } = usePacksSelection(nodePacks)
expect(isNoneInstalled.value).toBe(true)
})
})
describe('isMixed', () => {
it('should return true when some but not all packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2'),
createMockPack('pack3')
])
mockIsPackInstalled.mockImplementation((id: string) => {
return id === 'pack1' || id === 'pack2'
})
const { isMixed } = usePacksSelection(nodePacks)
expect(isMixed.value).toBe(true)
})
it('should return false when all packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
mockIsPackInstalled.mockReturnValue(true)
const { isMixed } = usePacksSelection(nodePacks)
expect(isMixed.value).toBe(false)
})
it('should return false when no packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
mockIsPackInstalled.mockReturnValue(false)
const { isMixed } = usePacksSelection(nodePacks)
expect(isMixed.value).toBe(false)
})
it('should return false for empty array', () => {
const nodePacks = ref<NodePack[]>([])
const { isMixed } = usePacksSelection(nodePacks)
expect(isMixed.value).toBe(false)
})
})
describe('selectionState', () => {
it('should return "all-installed" when all packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
mockIsPackInstalled.mockReturnValue(true)
const { selectionState } = usePacksSelection(nodePacks)
expect(selectionState.value).toBe('all-installed')
})
it('should return "none-installed" when no packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
mockIsPackInstalled.mockReturnValue(false)
const { selectionState } = usePacksSelection(nodePacks)
expect(selectionState.value).toBe('none-installed')
})
it('should return "mixed" when some packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2'),
createMockPack('pack3')
])
mockIsPackInstalled.mockImplementation((id: string) => id === 'pack1')
const { selectionState } = usePacksSelection(nodePacks)
expect(selectionState.value).toBe('mixed')
})
it('should update when installation status changes', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
mockIsPackInstalled.mockReturnValue(false)
const { selectionState } = usePacksSelection(nodePacks)
expect(selectionState.value).toBe('none-installed')
// Change mock to simulate installation
mockIsPackInstalled.mockReturnValue(true)
// Force reactivity update
nodePacks.value = [...nodePacks.value]
expect(selectionState.value).toBe('all-installed')
})
})
describe('edge cases', () => {
it('should handle packs with undefined ids', () => {
const nodePacks = ref<NodePack[]>([
{ ...createMockPack('pack1'), id: undefined as any },
createMockPack('pack2')
])
mockIsPackInstalled.mockImplementation((id: string) => id === 'pack2')
const { installedPacks, notInstalledPacks } = usePacksSelection(nodePacks)
expect(installedPacks.value).toHaveLength(1)
expect(installedPacks.value[0].id).toBe('pack2')
expect(notInstalledPacks.value).toHaveLength(1)
})
it('should handle dynamic changes to pack installation status', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
const installationStatus: Record<string, boolean> = {
pack1: false,
pack2: false
}
mockIsPackInstalled.mockImplementation(
(id: string) => installationStatus[id] || false
)
const { installedPacks, notInstalledPacks, selectionState } =
usePacksSelection(nodePacks)
expect(selectionState.value).toBe('none-installed')
expect(installedPacks.value).toHaveLength(0)
expect(notInstalledPacks.value).toHaveLength(2)
// Simulate installing pack1
installationStatus.pack1 = true
nodePacks.value = [...nodePacks.value] // Trigger reactivity
expect(selectionState.value).toBe('mixed')
expect(installedPacks.value).toHaveLength(1)
expect(notInstalledPacks.value).toHaveLength(1)
// Simulate installing pack2
installationStatus.pack2 = true
nodePacks.value = [...nodePacks.value] // Trigger reactivity
expect(selectionState.value).toBe('all-installed')
expect(installedPacks.value).toHaveLength(2)
expect(notInstalledPacks.value).toHaveLength(0)
})
})
})

View File

@@ -0,0 +1,384 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { usePacksStatus } from '@/composables/nodePack/usePacksStatus'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import type { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
type NodePack = components['schemas']['Node']
type NodeStatus = components['schemas']['NodeStatus']
type NodeVersionStatus = components['schemas']['NodeVersionStatus']
describe('usePacksStatus', () => {
let conflictDetectionStore: ReturnType<typeof useConflictDetectionStore>
const createMockPack = (
id: string,
status?: NodeStatus | NodeVersionStatus
): NodePack => ({
id,
name: `Pack ${id}`,
description: `Description for pack ${id}`,
category: 'Nodes',
author: 'Test Author',
license: 'MIT',
repository: 'https://github.com/test/pack',
tags: [],
status: (status || 'NodeStatusActive') as NodeStatus
})
const createMockConflict = (
packageId: string,
type: 'import_failed' | 'banned' | 'pending' = 'import_failed'
): ConflictDetectionResult => ({
package_id: packageId,
package_name: `Pack ${packageId}`,
has_conflict: true,
conflicts: [
{
type,
current_value: 'current',
required_value: 'required'
}
],
is_compatible: false
})
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
conflictDetectionStore = useConflictDetectionStore()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('hasImportFailed', () => {
it('should return true when at least one pack has import_failed conflict', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2'),
createMockPack('pack3')
])
// Set up mock conflicts
conflictDetectionStore.setConflictedPackages([
createMockConflict('pack2', 'import_failed'),
createMockConflict('pack3', 'banned')
])
const { hasImportFailed } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(true)
})
it('should return false when no pack has import_failed conflict', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
// Set up mock conflicts with no import_failed
conflictDetectionStore.setConflictedPackages([
createMockConflict('pack1', 'pending'),
createMockConflict('pack2', 'banned')
])
const { hasImportFailed } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(false)
})
it('should return false when no conflicts exist', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
conflictDetectionStore.setConflictedPackages([])
const { hasImportFailed } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(false)
})
it('should handle packs without ids', () => {
const nodePacks = ref<NodePack[]>([
{ ...createMockPack('pack1'), id: undefined as any },
createMockPack('pack2')
])
conflictDetectionStore.setConflictedPackages([
createMockConflict('pack2', 'import_failed')
])
const { hasImportFailed } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(true)
})
it('should update when conflicts change', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
conflictDetectionStore.setConflictedPackages([])
const { hasImportFailed } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(false)
// Add import_failed conflict
conflictDetectionStore.setConflictedPackages([
createMockConflict('pack1', 'import_failed')
])
expect(hasImportFailed.value).toBe(true)
})
})
describe('overallStatus', () => {
it('should prioritize banned status over all others', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeStatusActive'),
createMockPack('pack2', 'NodeStatusBanned'),
createMockPack('pack3', 'NodeVersionStatusDeleted')
])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeStatusBanned')
})
it('should prioritize version banned over deleted and active', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeStatusActive'),
createMockPack('pack2', 'NodeVersionStatusBanned'),
createMockPack('pack3', 'NodeVersionStatusDeleted')
])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeVersionStatusBanned')
})
it('should prioritize deleted status appropriately', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeStatusActive'),
createMockPack('pack2', 'NodeStatusDeleted'),
createMockPack('pack3', 'NodeVersionStatusActive')
])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeStatusDeleted')
})
it('should prioritize version deleted over flagged and active', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeVersionStatusFlagged'),
createMockPack('pack2', 'NodeVersionStatusDeleted'),
createMockPack('pack3', 'NodeVersionStatusActive')
])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeVersionStatusDeleted')
})
it('should prioritize flagged status over pending and active', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeVersionStatusPending'),
createMockPack('pack2', 'NodeVersionStatusFlagged'),
createMockPack('pack3', 'NodeVersionStatusActive')
])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeVersionStatusFlagged')
})
it('should prioritize pending status over active', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeVersionStatusActive'),
createMockPack('pack2', 'NodeVersionStatusPending'),
createMockPack('pack3', 'NodeStatusActive')
])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeVersionStatusPending')
})
it('should return NodeStatusActive when all packs are active', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeStatusActive'),
createMockPack('pack2', 'NodeStatusActive')
])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeStatusActive')
})
it('should return NodeStatusActive as default when all packs have no status', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
const { overallStatus } = usePacksStatus(nodePacks)
// Since createMockPack sets status to 'NodeStatusActive' by default
expect(overallStatus.value).toBe('NodeStatusActive')
})
it('should handle empty pack array', () => {
const nodePacks = ref<NodePack[]>([])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeVersionStatusActive')
})
it('should update when pack statuses change', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeStatusActive'),
createMockPack('pack2', 'NodeStatusActive')
])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeStatusActive')
// Change one pack to banned
nodePacks.value = [
createMockPack('pack1', 'NodeStatusBanned'),
createMockPack('pack2', 'NodeStatusActive')
]
expect(overallStatus.value).toBe('NodeStatusBanned')
})
})
describe('integration with import failures', () => {
it('should return NodeVersionStatusActive when import failures exist (handled separately)', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeStatusActive'),
createMockPack('pack2', 'NodeStatusActive')
])
conflictDetectionStore.setConflictedPackages([
createMockConflict('pack1', 'import_failed')
])
const { hasImportFailed, overallStatus } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(true)
// When import failed exists, it returns NodeVersionStatusActive
expect(overallStatus.value).toBe('NodeVersionStatusActive')
})
it('should return NodeVersionStatusActive when import failures exist even with banned status', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeStatusBanned'),
createMockPack('pack2', 'NodeStatusActive')
])
conflictDetectionStore.setConflictedPackages([
createMockConflict('pack2', 'import_failed')
])
const { hasImportFailed, overallStatus } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(true)
// Import failed takes priority and returns NodeVersionStatusActive
expect(overallStatus.value).toBe('NodeVersionStatusActive')
})
})
describe('edge cases', () => {
it('should handle multiple conflicts per package', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
conflictDetectionStore.setConflictedPackages([
{
package_id: 'pack1',
package_name: 'Pack pack1',
has_conflict: true,
conflicts: [
{
type: 'pending',
current_value: 'current1',
required_value: 'required1'
},
{
type: 'import_failed',
current_value: 'current2',
required_value: 'required2'
}
],
is_compatible: false
}
])
const { hasImportFailed } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(true)
})
it('should handle packs with no conflicts in store', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
const { hasImportFailed } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(false)
})
it('should handle mixed status types correctly', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeStatusBanned'),
createMockPack('pack2', 'NodeVersionStatusBanned'),
createMockPack('pack3', 'NodeStatusDeleted'),
createMockPack('pack4', 'NodeVersionStatusDeleted'),
createMockPack('pack5', 'NodeVersionStatusFlagged'),
createMockPack('pack6', 'NodeVersionStatusPending'),
createMockPack('pack7', 'NodeStatusActive'),
createMockPack('pack8', 'NodeVersionStatusActive')
])
const { overallStatus } = usePacksStatus(nodePacks)
// Should return the highest priority status (NodeStatusBanned)
expect(overallStatus.value).toBe('NodeStatusBanned')
})
it('should be reactive to nodePacks changes', () => {
const nodePacks = ref<NodePack[]>([])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeVersionStatusActive')
// Add packs
nodePacks.value = [
createMockPack('pack1', 'NodeStatusDeleted'),
createMockPack('pack2', 'NodeStatusActive')
]
expect(overallStatus.value).toBe('NodeStatusDeleted')
// Add a higher priority status
nodePacks.value.push(createMockPack('pack3', 'NodeStatusBanned'))
expect(overallStatus.value).toBe('NodeStatusBanned')
})
})
})

View File

@@ -0,0 +1,186 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('useConflictAcknowledgment', () => {
beforeEach(() => {
// Set up Pinia for each test
setActivePinia(createPinia())
// Clear localStorage before each test
localStorage.clear()
// Reset modules to ensure fresh state
vi.resetModules()
})
afterEach(() => {
localStorage.clear()
})
describe('initial state loading', () => {
it('should load empty state when localStorage is empty', async () => {
vi.resetModules()
const { useConflictAcknowledgment } = await import(
'@/composables/useConflictAcknowledgment'
)
const { acknowledgmentState } = useConflictAcknowledgment()
expect(acknowledgmentState.value).toEqual({
modal_dismissed: false,
red_dot_dismissed: false,
warning_banner_dismissed: false
})
})
it('should load existing state from localStorage', async () => {
// Pre-populate localStorage with JSON values (as useStorage expects)
localStorage.setItem('Comfy.ConflictModalDismissed', JSON.stringify(true))
localStorage.setItem(
'Comfy.ConflictRedDotDismissed',
JSON.stringify(true)
)
localStorage.setItem(
'Comfy.ConflictWarningBannerDismissed',
JSON.stringify(true)
)
// Need to import the module after localStorage is set
vi.resetModules()
const { useConflictAcknowledgment } = await import(
'@/composables/useConflictAcknowledgment'
)
const { acknowledgmentState } = useConflictAcknowledgment()
expect(acknowledgmentState.value).toEqual({
modal_dismissed: true,
red_dot_dismissed: true,
warning_banner_dismissed: true
})
})
})
describe('dismissal functions', () => {
it('should mark conflicts as seen with unified function', async () => {
vi.resetModules()
const { useConflictAcknowledgment } = await import(
'@/composables/useConflictAcknowledgment'
)
const { markConflictsAsSeen, acknowledgmentState } =
useConflictAcknowledgment()
markConflictsAsSeen()
expect(acknowledgmentState.value.modal_dismissed).toBe(true)
})
it('should dismiss red dot notification', async () => {
vi.resetModules()
const { useConflictAcknowledgment } = await import(
'@/composables/useConflictAcknowledgment'
)
const { dismissRedDotNotification, acknowledgmentState } =
useConflictAcknowledgment()
dismissRedDotNotification()
expect(acknowledgmentState.value.red_dot_dismissed).toBe(true)
})
it('should dismiss warning banner', async () => {
vi.resetModules()
const { useConflictAcknowledgment } = await import(
'@/composables/useConflictAcknowledgment'
)
const { dismissWarningBanner, acknowledgmentState } =
useConflictAcknowledgment()
dismissWarningBanner()
expect(acknowledgmentState.value.warning_banner_dismissed).toBe(true)
})
it('should mark all conflicts as seen', async () => {
vi.resetModules()
const { useConflictAcknowledgment } = await import(
'@/composables/useConflictAcknowledgment'
)
const { markConflictsAsSeen, acknowledgmentState } =
useConflictAcknowledgment()
markConflictsAsSeen()
expect(acknowledgmentState.value.modal_dismissed).toBe(true)
expect(acknowledgmentState.value.red_dot_dismissed).toBe(true)
expect(acknowledgmentState.value.warning_banner_dismissed).toBe(true)
})
})
describe('computed properties', () => {
it('should calculate shouldShowConflictModal correctly', async () => {
// Need fresh module import to ensure clean state
vi.resetModules()
const { useConflictAcknowledgment } = await import(
'@/composables/useConflictAcknowledgment'
)
const { shouldShowConflictModal, markConflictsAsSeen } =
useConflictAcknowledgment()
expect(shouldShowConflictModal.value).toBe(true)
markConflictsAsSeen()
expect(shouldShowConflictModal.value).toBe(false)
})
it('should calculate shouldShowRedDot correctly based on conflicts', async () => {
vi.resetModules()
const { useConflictAcknowledgment } = await import(
'@/composables/useConflictAcknowledgment'
)
const { shouldShowRedDot, dismissRedDotNotification } =
useConflictAcknowledgment()
// Initially false because no conflicts exist
expect(shouldShowRedDot.value).toBe(false)
dismissRedDotNotification()
expect(shouldShowRedDot.value).toBe(false)
})
it('should calculate shouldShowManagerBanner correctly', async () => {
vi.resetModules()
const { useConflictAcknowledgment } = await import(
'@/composables/useConflictAcknowledgment'
)
const { shouldShowManagerBanner, dismissWarningBanner } =
useConflictAcknowledgment()
// Initially false because no conflicts exist
expect(shouldShowManagerBanner.value).toBe(false)
dismissWarningBanner()
expect(shouldShowManagerBanner.value).toBe(false)
})
})
describe('localStorage persistence', () => {
it('should persist to localStorage automatically', async () => {
// Need fresh module import to ensure clean state
vi.resetModules()
const { useConflictAcknowledgment } = await import(
'@/composables/useConflictAcknowledgment'
)
const { markConflictsAsSeen, dismissWarningBanner } =
useConflictAcknowledgment()
markConflictsAsSeen()
dismissWarningBanner()
// Wait a tick for useStorage to sync
await new Promise((resolve) => setTimeout(resolve, 10))
// VueUse useStorage should automatically persist to localStorage as JSON
expect(localStorage.getItem('Comfy.ConflictModalDismissed')).toBe('true')
expect(localStorage.getItem('Comfy.ConflictWarningBannerDismissed')).toBe(
'true'
)
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { isReactive, isReadonly } from 'vue'
import {
ServerFeatureFlag,
useFeatureFlags
} from '@/composables/useFeatureFlags'
import { api } from '@/scripts/api'
// Mock the API module
vi.mock('@/scripts/api', () => ({
api: {
getServerFeature: vi.fn()
}
}))
describe('useFeatureFlags', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('flags object', () => {
it('should provide reactive readonly flags', () => {
const { flags } = useFeatureFlags()
expect(isReadonly(flags)).toBe(true)
expect(isReactive(flags)).toBe(true)
})
it('should access supportsPreviewMetadata', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
return true as any
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.supportsPreviewMetadata).toBe(true)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA
)
})
it('should access maxUploadSize', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.MAX_UPLOAD_SIZE)
return 209715200 as any // 200MB
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.maxUploadSize).toBe(209715200)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.MAX_UPLOAD_SIZE
)
})
it('should access supportsManagerV4', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.MANAGER_SUPPORTS_V4) return true as any
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.supportsManagerV4).toBe(true)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.MANAGER_SUPPORTS_V4
)
})
it('should return undefined when features are not available and no default provided', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(_path, defaultValue) => defaultValue as any
)
const { flags } = useFeatureFlags()
expect(flags.supportsPreviewMetadata).toBeUndefined()
expect(flags.maxUploadSize).toBeUndefined()
expect(flags.supportsManagerV4).toBeUndefined()
})
})
describe('featureFlag', () => {
it('should create reactive computed for custom feature flags', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === 'custom.feature') return 'custom-value' as any
return defaultValue
}
)
const { featureFlag } = useFeatureFlags()
const customFlag = featureFlag('custom.feature', 'default')
expect(customFlag.value).toBe('custom-value')
expect(api.getServerFeature).toHaveBeenCalledWith(
'custom.feature',
'default'
)
})
it('should handle nested paths', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === 'extension.custom.nested.feature') return true as any
return defaultValue
}
)
const { featureFlag } = useFeatureFlags()
const nestedFlag = featureFlag('extension.custom.nested.feature', false)
expect(nestedFlag.value).toBe(true)
})
it('should work with ServerFeatureFlag enum', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.MAX_UPLOAD_SIZE)
return 104857600 as any
return defaultValue
}
)
const { featureFlag } = useFeatureFlags()
const maxUploadSize = featureFlag(ServerFeatureFlag.MAX_UPLOAD_SIZE)
expect(maxUploadSize.value).toBe(104857600)
})
})
})

View File

@@ -0,0 +1,198 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { useImportFailedDetection } from '@/composables/useImportFailedDetection'
import * as dialogService from '@/services/dialogService'
import * as comfyManagerStore from '@/stores/comfyManagerStore'
import * as conflictDetectionStore from '@/stores/conflictDetectionStore'
// Mock the stores and services
vi.mock('@/stores/comfyManagerStore')
vi.mock('@/stores/conflictDetectionStore')
vi.mock('@/services/dialogService')
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-i18n')>()
return {
...actual,
useI18n: () => ({
t: vi.fn((key: string) => key)
})
}
})
describe('useImportFailedDetection', () => {
let mockComfyManagerStore: any
let mockConflictDetectionStore: any
let mockDialogService: any
beforeEach(() => {
setActivePinia(createPinia())
mockComfyManagerStore = {
isPackInstalled: vi.fn()
}
mockConflictDetectionStore = {
getConflictsForPackageByID: vi.fn()
}
mockDialogService = {
showErrorDialog: vi.fn()
}
vi.mocked(comfyManagerStore.useComfyManagerStore).mockReturnValue(
mockComfyManagerStore
)
vi.mocked(conflictDetectionStore.useConflictDetectionStore).mockReturnValue(
mockConflictDetectionStore
)
vi.mocked(dialogService.useDialogService).mockReturnValue(mockDialogService)
})
it('should return false for importFailed when package is not installed', () => {
mockComfyManagerStore.isPackInstalled.mockReturnValue(false)
const { importFailed } = useImportFailedDetection('test-package')
expect(importFailed.value).toBe(false)
})
it('should return false for importFailed when no conflicts exist', () => {
mockComfyManagerStore.isPackInstalled.mockReturnValue(true)
mockConflictDetectionStore.getConflictsForPackageByID.mockReturnValue(null)
const { importFailed } = useImportFailedDetection('test-package')
expect(importFailed.value).toBe(false)
})
it('should return false for importFailed when conflicts exist but no import_failed type', () => {
mockComfyManagerStore.isPackInstalled.mockReturnValue(true)
mockConflictDetectionStore.getConflictsForPackageByID.mockReturnValue({
package_id: 'test-package',
conflicts: [
{ type: 'dependency', message: 'Dependency conflict' },
{ type: 'version', message: 'Version conflict' }
]
})
const { importFailed } = useImportFailedDetection('test-package')
expect(importFailed.value).toBe(false)
})
it('should return true for importFailed when import_failed conflicts exist', () => {
mockComfyManagerStore.isPackInstalled.mockReturnValue(true)
mockConflictDetectionStore.getConflictsForPackageByID.mockReturnValue({
package_id: 'test-package',
conflicts: [
{
type: 'import_failed',
message: 'Import failed',
required_value: 'Error details'
},
{ type: 'dependency', message: 'Dependency conflict' }
]
})
const { importFailed } = useImportFailedDetection('test-package')
expect(importFailed.value).toBe(true)
})
it('should work with computed ref packageId', () => {
const packageId = ref('test-package')
mockComfyManagerStore.isPackInstalled.mockReturnValue(true)
mockConflictDetectionStore.getConflictsForPackageByID.mockReturnValue({
package_id: 'test-package',
conflicts: [
{
type: 'import_failed',
message: 'Import failed',
required_value: 'Error details'
}
]
})
const { importFailed } = useImportFailedDetection(
computed(() => packageId.value)
)
expect(importFailed.value).toBe(true)
// Change packageId
packageId.value = 'another-package'
mockConflictDetectionStore.getConflictsForPackageByID.mockReturnValue(null)
expect(importFailed.value).toBe(false)
})
it('should return correct importFailedInfo', () => {
const importFailedConflicts = [
{
type: 'import_failed',
message: 'Import failed 1',
required_value: 'Error 1'
},
{
type: 'import_failed',
message: 'Import failed 2',
required_value: 'Error 2'
}
]
mockComfyManagerStore.isPackInstalled.mockReturnValue(true)
mockConflictDetectionStore.getConflictsForPackageByID.mockReturnValue({
package_id: 'test-package',
conflicts: [
...importFailedConflicts,
{ type: 'dependency', message: 'Dependency conflict' }
]
})
const { importFailedInfo } = useImportFailedDetection('test-package')
expect(importFailedInfo.value).toEqual(importFailedConflicts)
})
it('should show error dialog when showImportFailedDialog is called', () => {
const importFailedConflicts = [
{
type: 'import_failed',
message: 'Import failed',
required_value: 'Error details'
}
]
mockComfyManagerStore.isPackInstalled.mockReturnValue(true)
mockConflictDetectionStore.getConflictsForPackageByID.mockReturnValue({
package_id: 'test-package',
conflicts: importFailedConflicts
})
const { showImportFailedDialog } = useImportFailedDetection('test-package')
showImportFailedDialog()
expect(mockDialogService.showErrorDialog).toHaveBeenCalledWith(
expect.any(Error),
{
title: 'manager.failedToInstall',
reportType: 'importFailedError'
}
)
})
it('should handle null packageId', () => {
const { importFailed, isInstalled } = useImportFailedDetection(null)
expect(importFailed.value).toBe(false)
expect(isInstalled.value).toBe(false)
})
it('should handle undefined packageId', () => {
const { importFailed, isInstalled } = useImportFailedDetection(undefined)
expect(importFailed.value).toBe(false)
expect(isInstalled.value).toBe(false)
})
})

View File

@@ -0,0 +1,165 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useManagerQueue } from '@/composables/useManagerQueue'
import { components } from '@/types/generatedManagerTypes'
// Mock dialog service
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showManagerProgressDialog: vi.fn()
})
}))
// Mock the app API
vi.mock('@/scripts/app', () => ({
app: {
api: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
clientId: 'test-client-id'
}
}
}))
type ManagerTaskHistory = Record<
string,
components['schemas']['TaskHistoryItem']
>
type ManagerTaskQueue = components['schemas']['TaskStateMessage']
describe('useManagerQueue', () => {
let taskHistory: any
let taskQueue: any
let installedPacks: any
const createManagerQueue = () => {
taskHistory = ref<ManagerTaskHistory>({})
taskQueue = ref<ManagerTaskQueue>({
history: {},
running_queue: [],
pending_queue: [],
installed_packs: {}
})
installedPacks = ref({})
return useManagerQueue(taskHistory, taskQueue, installedPacks)
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('initialization', () => {
it('should initialize with empty state', () => {
const queue = createManagerQueue()
expect(queue.currentQueueLength.value).toBe(0)
expect(queue.allTasksDone.value).toBe(true)
expect(queue.isProcessing.value).toBe(false)
expect(queue.historyCount.value).toBe(0)
})
})
describe('task state management', () => {
it('should track task queue length', () => {
const queue = createManagerQueue()
// Add tasks to queue
taskQueue.value.running_queue = [
{
ui_id: 'task1',
client_id: 'test-client-id',
task_name: 'Installing pack1'
}
]
taskQueue.value.pending_queue = [
{
ui_id: 'task2',
client_id: 'test-client-id',
task_name: 'Installing pack2'
}
]
expect(queue.currentQueueLength.value).toBe(2)
expect(queue.allTasksDone.value).toBe(false)
})
it('should handle empty queues', () => {
const queue = createManagerQueue()
taskQueue.value.running_queue = []
taskQueue.value.pending_queue = []
expect(queue.currentQueueLength.value).toBe(0)
expect(queue.allTasksDone.value).toBe(true)
})
})
describe('task history management', () => {
it('should track task history count', () => {
const queue = createManagerQueue()
taskHistory.value = {
task1: {
ui_id: 'task1',
client_id: 'test-client-id',
status: { status_str: 'success', completed: true }
},
task2: {
ui_id: 'task2',
client_id: 'test-client-id',
status: { status_str: 'success', completed: true }
}
}
expect(queue.historyCount.value).toBe(2)
})
it('should filter tasks by client ID', () => {
const queue = createManagerQueue()
const mockState = {
history: {
task1: {
ui_id: 'task1',
client_id: 'test-client-id', // This client
kind: 'install',
timestamp: '2024-01-01T00:00:00Z',
result: 'success',
status: {
status_str: 'success' as const,
completed: true,
messages: []
}
},
task2: {
ui_id: 'task2',
client_id: 'other-client-id', // Different client
kind: 'install',
timestamp: '2024-01-01T00:00:00Z',
result: 'success',
status: {
status_str: 'success' as const,
completed: true,
messages: []
}
}
},
running_queue: [],
pending_queue: [],
installed_packs: {}
}
queue.updateTaskState(mockState)
// Should only include task from this client
expect(taskHistory.value).toHaveProperty('task1')
expect(taskHistory.value).not.toHaveProperty('task2')
})
})
})

View File

@@ -3,23 +3,26 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useNodeChatHistory } from '@/composables/node/useNodeChatHistory'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
vi.mock('@/composables/widgets/useChatHistoryWidget', () => ({
useChatHistoryWidget: () => {
return (node: any, inputSpec: any) => {
const widget = {
name: inputSpec.name,
type: inputSpec.type
}
vi.mock(
'@/renderer/extensions/vueNodes/widgets/composables/useChatHistoryWidget',
() => ({
useChatHistoryWidget: () => {
return (node: any, inputSpec: any) => {
const widget = {
name: inputSpec.name,
type: inputSpec.type
}
if (!node.widgets) {
node.widgets = []
}
node.widgets.push(widget)
if (!node.widgets) {
node.widgets = []
}
node.widgets.push(widget)
return widget
return widget
}
}
}
}))
})
)
// Mock LGraphNode type
type MockNode = {

View File

@@ -0,0 +1,360 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
import { useUpdateAvailableNodes } from '@/composables/nodePack/useUpdateAvailableNodes'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
// Import mocked utils
import { compareVersions, isSemVer } from '@/utils/formatUtil'
// Mock Vue's onMounted to execute immediately for testing
vi.mock('vue', async () => {
const actual = await vi.importActual('vue')
return {
...actual,
onMounted: (cb: () => void) => cb()
}
})
// Mock the dependencies
vi.mock('@/composables/nodePack/useInstalledPacks', () => ({
useInstalledPacks: vi.fn()
}))
vi.mock('@/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn()
}))
vi.mock('@/utils/formatUtil', () => ({
compareVersions: vi.fn(),
isSemVer: vi.fn()
}))
const mockUseInstalledPacks = vi.mocked(useInstalledPacks)
const mockUseComfyManagerStore = vi.mocked(useComfyManagerStore)
const mockCompareVersions = vi.mocked(compareVersions)
const mockIsSemVer = vi.mocked(isSemVer)
describe('useUpdateAvailableNodes', () => {
const mockInstalledPacks = [
{
id: 'pack-1',
name: 'Outdated Pack',
latest_version: { version: '2.0.0' }
},
{
id: 'pack-2',
name: 'Up to Date Pack',
latest_version: { version: '1.0.0' }
},
{
id: 'pack-3',
name: 'Nightly Pack',
latest_version: { version: '1.5.0' }
},
{
id: 'pack-4',
name: 'No Latest Version',
latest_version: null
}
]
const mockStartFetchInstalled = vi.fn()
const mockIsPackInstalled = vi.fn()
const mockGetInstalledPackVersion = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
// Default setup
mockIsPackInstalled.mockReturnValue(true)
mockGetInstalledPackVersion.mockImplementation((id: string) => {
switch (id) {
case 'pack-1':
return '1.0.0' // outdated
case 'pack-2':
return '1.0.0' // up to date
case 'pack-3':
return 'nightly-abc123' // nightly
case 'pack-4':
return '1.0.0' // no latest version
default:
return '1.0.0'
}
})
mockIsSemVer.mockImplementation(
(version: string): version is `${number}.${number}.${number}` => {
return !version.includes('nightly')
}
)
mockCompareVersions.mockImplementation(
(latest: string | undefined, installed: string | undefined) => {
if (latest === '2.0.0' && installed === '1.0.0') return 1 // outdated
if (latest === '1.0.0' && installed === '1.0.0') return 0 // up to date
return 0
}
)
mockUseComfyManagerStore.mockReturnValue({
isPackInstalled: mockIsPackInstalled,
getInstalledPackVersion: mockGetInstalledPackVersion
} as any)
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([]),
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
})
describe('core filtering logic', () => {
it('identifies outdated packs correctly', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref(mockInstalledPacks),
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
// Should only include pack-1 (outdated)
expect(updateAvailableNodePacks.value).toHaveLength(1)
expect(updateAvailableNodePacks.value[0].id).toBe('pack-1')
})
it('excludes up-to-date packs', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[1]]), // pack-2: up to date
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
expect(updateAvailableNodePacks.value).toHaveLength(0)
})
it('excludes nightly packs from updates', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[2]]), // pack-3: nightly
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
expect(updateAvailableNodePacks.value).toHaveLength(0)
})
it('excludes packs with no latest version', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[3]]), // pack-4: no latest version
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
expect(updateAvailableNodePacks.value).toHaveLength(0)
})
it('excludes uninstalled packs', () => {
mockIsPackInstalled.mockReturnValue(false)
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref(mockInstalledPacks),
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
expect(updateAvailableNodePacks.value).toHaveLength(0)
})
it('returns empty array when no installed packs exist', () => {
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
expect(updateAvailableNodePacks.value).toEqual([])
})
})
describe('hasUpdateAvailable computed', () => {
it('returns true when updates are available', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { hasUpdateAvailable } = useUpdateAvailableNodes()
expect(hasUpdateAvailable.value).toBe(true)
})
it('returns false when no updates are available', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[1]]), // pack-2: up to date
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { hasUpdateAvailable } = useUpdateAvailableNodes()
expect(hasUpdateAvailable.value).toBe(false)
})
})
describe('automatic data fetching', () => {
it('fetches installed packs automatically when none exist', () => {
useUpdateAvailableNodes()
expect(mockStartFetchInstalled).toHaveBeenCalledOnce()
})
it('does not fetch when packs already exist', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref(mockInstalledPacks),
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
useUpdateAvailableNodes()
expect(mockStartFetchInstalled).not.toHaveBeenCalled()
})
it('does not fetch when already loading', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([]),
isLoading: ref(true),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
useUpdateAvailableNodes()
expect(mockStartFetchInstalled).not.toHaveBeenCalled()
})
})
describe('state management', () => {
it('exposes loading state from useInstalledPacks', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([]),
isLoading: ref(true),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { isLoading } = useUpdateAvailableNodes()
expect(isLoading.value).toBe(true)
})
it('exposes error state from useInstalledPacks', () => {
const testError = 'Failed to fetch installed packs'
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([]),
isLoading: ref(false),
error: ref(testError),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { error } = useUpdateAvailableNodes()
expect(error.value).toBe(testError)
})
})
describe('reactivity', () => {
it('updates when installed packs change', async () => {
const installedPacksRef = ref([])
mockUseInstalledPacks.mockReturnValue({
installedPacks: installedPacksRef,
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { updateAvailableNodePacks, hasUpdateAvailable } =
useUpdateAvailableNodes()
// Initially empty
expect(updateAvailableNodePacks.value).toEqual([])
expect(hasUpdateAvailable.value).toBe(false)
// Update installed packs
installedPacksRef.value = [mockInstalledPacks[0]] as any // pack-1: outdated
await nextTick()
// Should update available updates
expect(updateAvailableNodePacks.value).toHaveLength(1)
expect(hasUpdateAvailable.value).toBe(true)
})
})
describe('version comparison logic', () => {
it('calls compareVersions with correct parameters', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
// Access the computed to trigger the logic
expect(updateAvailableNodePacks.value).toBeDefined()
expect(mockCompareVersions).toHaveBeenCalledWith('2.0.0', '1.0.0')
})
it('calls isSemVer to check nightly versions', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[2]]), // pack-3: nightly
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
// Access the computed to trigger the logic
expect(updateAvailableNodePacks.value).toBeDefined()
expect(mockIsSemVer).toHaveBeenCalledWith('nightly-abc123')
})
it('calls isPackInstalled for each pack', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref(mockInstalledPacks),
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
// Access the computed to trigger the logic
expect(updateAvailableNodePacks.value).toBeDefined()
expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-1')
expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-2')
expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-3')
expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-4')
})
})
})

View File

@@ -1,329 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useManagerQueue } from '@/composables/useManagerQueue'
import { api } from '@/scripts/api'
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}
}))
describe('useManagerQueue', () => {
const createMockTask = (result: any = 'result') => ({
task: vi.fn().mockResolvedValue(result),
onComplete: vi.fn()
})
const createQueueWithMockTask = () => {
const queue = useManagerQueue()
const mockTask = createMockTask()
queue.enqueueTask(mockTask)
return { queue, mockTask }
}
const getEventListenerCallback = () =>
vi.mocked(api.addEventListener).mock.calls[0][1]
const simulateServerStatus = async (status: 'done' | 'in_progress') => {
const event = new CustomEvent('cm-queue-status', {
detail: { status }
})
getEventListenerCallback()!(event as any)
await nextTick()
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('initialization', () => {
it('should initialize with empty queue and DONE status', () => {
const queue = useManagerQueue()
expect(queue.queueLength.value).toBe(0)
expect(queue.statusMessage.value).toBe('done')
expect(queue.allTasksDone.value).toBe(true)
})
})
describe('queue management', () => {
it('should add tasks to the queue', () => {
const queue = useManagerQueue()
const mockTask = createMockTask()
queue.enqueueTask(mockTask)
expect(queue.queueLength.value).toBe(1)
expect(queue.allTasksDone.value).toBe(false)
})
it('should clear the queue when clearQueue is called', () => {
const queue = useManagerQueue()
// Add some tasks
queue.enqueueTask(createMockTask())
queue.enqueueTask(createMockTask())
expect(queue.queueLength.value).toBe(2)
// Clear the queue
queue.clearQueue()
expect(queue.queueLength.value).toBe(0)
expect(queue.allTasksDone.value).toBe(true)
})
})
describe('server status handling', () => {
it('should update server status when receiving websocket events', async () => {
const queue = useManagerQueue()
await simulateServerStatus('in_progress')
expect(queue.statusMessage.value).toBe('in_progress')
expect(queue.allTasksDone.value).toBe(false)
})
it('should handle invalid status values gracefully', async () => {
const queue = useManagerQueue()
// Simulate an invalid status
const event = new CustomEvent('cm-queue-status', {
detail: null as any
})
getEventListenerCallback()!(event)
await nextTick()
// Should maintain the default status
expect(queue.statusMessage.value).toBe('done')
})
it('should handle missing status property gracefully', async () => {
const queue = useManagerQueue()
// Simulate a detail object without status property
const event = new CustomEvent('cm-queue-status', {
detail: { someOtherProperty: 'value' } as any
})
getEventListenerCallback()!(event)
await nextTick()
// Should maintain the default status
expect(queue.statusMessage.value).toBe('done')
})
})
describe('task execution', () => {
it('should start the next task when server is idle and queue has items', async () => {
const { queue, mockTask } = createQueueWithMockTask()
await simulateServerStatus('done')
// Task should have been started
expect(mockTask.task).toHaveBeenCalled()
expect(queue.queueLength.value).toBe(0)
})
it('should execute onComplete callback when task completes and server becomes idle', async () => {
const { mockTask } = createQueueWithMockTask()
// Start the task
await simulateServerStatus('done')
expect(mockTask.task).toHaveBeenCalled()
// Simulate task completion
await mockTask.task.mock.results[0].value
// Simulate server cycle (in_progress -> done)
await simulateServerStatus('in_progress')
expect(mockTask.onComplete).not.toHaveBeenCalled()
await simulateServerStatus('done')
expect(mockTask.onComplete).toHaveBeenCalled()
})
it('should handle tasks without onComplete callback', async () => {
const queue = useManagerQueue()
const mockTask = { task: vi.fn().mockResolvedValue('result') }
queue.enqueueTask(mockTask)
// Start the task
await simulateServerStatus('done')
expect(mockTask.task).toHaveBeenCalled()
// Simulate task completion
await mockTask.task.mock.results[0].value
// Simulate server cycle
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
// Should not throw errors even without onComplete
expect(queue.allTasksDone.value).toBe(true)
})
it('should process multiple tasks in sequence', async () => {
const queue = useManagerQueue()
const mockTask1 = createMockTask('result1')
const mockTask2 = createMockTask('result2')
// Add tasks to the queue
queue.enqueueTask(mockTask1)
queue.enqueueTask(mockTask2)
expect(queue.queueLength.value).toBe(2)
// Process first task
await simulateServerStatus('done')
expect(mockTask1.task).toHaveBeenCalled()
expect(queue.queueLength.value).toBe(1)
// Complete first task
await mockTask1.task.mock.results[0].value
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
expect(mockTask1.onComplete).toHaveBeenCalled()
// Process second task
expect(mockTask2.task).toHaveBeenCalled()
expect(queue.queueLength.value).toBe(0)
// Complete second task
await mockTask2.task.mock.results[0].value
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
expect(mockTask2.onComplete).toHaveBeenCalled()
// Queue should be empty and all tasks done
expect(queue.queueLength.value).toBe(0)
expect(queue.allTasksDone.value).toBe(true)
})
it('should handle task that returns rejected promise', async () => {
const queue = useManagerQueue()
const mockTask = {
task: vi.fn().mockRejectedValue(new Error('Task failed')),
onComplete: vi.fn()
}
queue.enqueueTask(mockTask)
// Start the task
await simulateServerStatus('done')
expect(mockTask.task).toHaveBeenCalled()
// Let the promise rejection happen
try {
await mockTask.task()
} catch (e) {
// Ignore the error
}
// Simulate server cycle
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
// onComplete should still be called for failed tasks
expect(mockTask.onComplete).toHaveBeenCalled()
})
it('should handle multiple multiple tasks enqueued at once while server busy', async () => {
const queue = useManagerQueue()
const mockTask1 = createMockTask()
const mockTask2 = createMockTask()
const mockTask3 = createMockTask()
// Three tasks enqueued at once
await simulateServerStatus('in_progress')
await Promise.all([
queue.enqueueTask(mockTask1),
queue.enqueueTask(mockTask2),
queue.enqueueTask(mockTask3)
])
// Task 1
await simulateServerStatus('done')
expect(mockTask1.task).toHaveBeenCalled()
// Verify state of onComplete callbacks
expect(mockTask1.onComplete).toHaveBeenCalled()
expect(mockTask2.onComplete).not.toHaveBeenCalled()
expect(mockTask3.onComplete).not.toHaveBeenCalled()
// Verify state of queue
expect(queue.queueLength.value).toBe(2)
expect(queue.allTasksDone.value).toBe(false)
// Task 2
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
expect(mockTask2.task).toHaveBeenCalled()
// Verify state of onComplete callbacks
expect(mockTask2.onComplete).toHaveBeenCalled()
expect(mockTask3.onComplete).not.toHaveBeenCalled()
// Verify state of queue
expect(queue.queueLength.value).toBe(1)
expect(queue.allTasksDone.value).toBe(false)
// Task 3
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
// Verify state of onComplete callbacks
expect(mockTask3.task).toHaveBeenCalled()
expect(mockTask3.onComplete).toHaveBeenCalled()
// Verify state of queue
expect(queue.queueLength.value).toBe(0)
expect(queue.allTasksDone.value).toBe(true)
})
it('should handle adding tasks while processing is in progress', async () => {
const queue = useManagerQueue()
const mockTask1 = createMockTask()
const mockTask2 = createMockTask()
// Add first task and start processing
queue.enqueueTask(mockTask1)
await simulateServerStatus('done')
expect(mockTask1.task).toHaveBeenCalled()
// Add second task while first is processing
queue.enqueueTask(mockTask2)
expect(queue.queueLength.value).toBe(1)
// Complete first task
await mockTask1.task.mock.results[0].value
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
// Second task should now be processed
expect(mockTask2.task).toHaveBeenCalled()
})
it('should handle server status changes without tasks in queue', async () => {
const queue = useManagerQueue()
// Cycle server status without any tasks
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
// Should not cause any errors
expect(queue.allTasksDone.value).toBe(true)
})
})
})

View File

@@ -622,38 +622,4 @@ describe('LGraphNode', () => {
delete (node.constructor as any).slot_start_y
})
})
describe('getInputPos', () => {
test('should call getInputSlotPos with the correct input slot from inputs array', () => {
const input0: INodeInputSlot = {
name: 'in0',
type: 'string',
link: null,
boundingRect: new Float32Array([0, 0, 0, 0])
}
const input1: INodeInputSlot = {
name: 'in1',
type: 'number',
link: null,
boundingRect: new Float32Array([0, 0, 0, 0]),
pos: [5, 45]
}
node.inputs = [input0, input1]
const spy = vi.spyOn(node, 'getInputSlotPos')
node.getInputPos(1)
expect(spy).toHaveBeenCalledWith(input1)
const expectedPos: Point = [100 + 5, 200 + 45]
expect(node.getInputPos(1)).toEqual(expectedPos)
spy.mockClear()
node.getInputPos(0)
expect(spy).toHaveBeenCalledWith(input0)
const slotIndex = 0
const nodeOffsetY = (node.constructor as any).slot_start_y || 0
const expectedDefaultY =
200 + (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT + nodeOffsetY
const expectedDefaultX = 100 + LiteGraph.NODE_SLOT_HEIGHT * 0.5
expect(node.getInputPos(0)).toEqual([expectedDefaultX, expectedDefaultY])
spy.mockRestore()
})
})
})

View File

@@ -62,6 +62,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
@@ -83,6 +84,7 @@ LGraph {
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"onMouseDown": [Function],
"order": 0,
"outputs": [],
"progress": undefined,
@@ -133,6 +135,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
@@ -154,6 +157,7 @@ LGraph {
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"onMouseDown": [Function],
"order": 0,
"outputs": [],
"progress": undefined,
@@ -205,6 +209,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
@@ -226,6 +231,7 @@ LGraph {
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"onMouseDown": [Function],
"order": 0,
"outputs": [],
"progress": undefined,

View File

@@ -11,6 +11,17 @@ LiteGraphGlobal {
"CARD_SHAPE": 4,
"CENTER": 5,
"CIRCLE_SHAPE": 3,
"COMFY_VUE_NODE_DIMENSIONS": {
"components": {
"HEADER_HEIGHT": 34,
"SLOT_HEIGHT": 24,
"STANDARD_WIDGET_HEIGHT": 30,
},
"spacing": {
"BETWEEN_SLOTS_AND_BODY": 8,
"BETWEEN_WIDGETS": 8,
},
},
"CONNECTING_LINK_COLOR": "#AFA",
"Classes": {
"InputIndicators": [Function],
@@ -102,16 +113,7 @@ LiteGraphGlobal {
"Reroute": [Function],
"SPLINE_LINK": 2,
"STRAIGHT_LINK": 0,
"SlotDirection": {
"1": "Up",
"2": "Down",
"3": "Left",
"4": "Right",
"Down": 2,
"Left": 3,
"Right": 4,
"Up": 1,
},
"SlotDirection": {},
"SlotShape": {
"1": "Box",
"3": "Circle",
@@ -199,5 +201,6 @@ LiteGraphGlobal {
"truncateWidgetValuesFirst": false,
"use_uuids": false,
"uuidv4": [Function],
"vueNodesMode": false,
}
`;

View File

@@ -169,140 +169,3 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
capture.cleanup()
}
})
/**
* Fixtures that test edge cases and error conditions.
* These may leave the system in an invalid state and should be used carefully.
*/
export interface EdgeCaseFixtures {
/** Subgraph with circular references (for testing recursion detection) */
circularSubgraph: {
rootGraph: LGraph
subgraphA: Subgraph
subgraphB: Subgraph
nodeA: SubgraphNode
nodeB: SubgraphNode
}
/** Deeply nested subgraphs approaching the theoretical limit */
deeplyNestedSubgraph: ReturnType<typeof createNestedSubgraphs>
/** Subgraph with maximum inputs and outputs */
maxIOSubgraph: Subgraph
}
/**
* Test with edge case fixtures. Use sparingly and with caution.
* These tests may intentionally create invalid states.
*/
export const edgeCaseTest = subgraphTest.extend<EdgeCaseFixtures>({
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
// eslint-disable-next-line no-empty-pattern
circularSubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
const rootGraph = new LGraph()
// Create two subgraphs that will reference each other
const subgraphA = createTestSubgraph({
name: 'Subgraph A',
inputs: [{ name: 'input', type: '*' }],
outputs: [{ name: 'output', type: '*' }]
})
const subgraphB = createTestSubgraph({
name: 'Subgraph B',
inputs: [{ name: 'input', type: '*' }],
outputs: [{ name: 'output', type: '*' }]
})
// Create instances (this doesn't create circular refs by itself)
const nodeA = createTestSubgraphNode(subgraphA, { pos: [100, 100] })
const nodeB = createTestSubgraphNode(subgraphB, { pos: [300, 100] })
// Add nodes to root graph
rootGraph.add(nodeA)
rootGraph.add(nodeB)
await use({
rootGraph,
subgraphA,
subgraphB,
nodeA,
nodeB
})
},
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
// eslint-disable-next-line no-empty-pattern
deeplyNestedSubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
// Create a very deep nesting structure (but not exceeding MAX_NESTED_SUBGRAPHS)
const nested = createNestedSubgraphs({
depth: 50, // Deep but reasonable
nodesPerLevel: 1,
inputsPerSubgraph: 1,
outputsPerSubgraph: 1
})
await use(nested)
},
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
// eslint-disable-next-line no-empty-pattern
maxIOSubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
// Create a subgraph with many inputs and outputs
const inputs = Array.from({ length: 20 }, (_, i) => ({
name: `input_${i}`,
type: i % 2 === 0 ? 'number' : ('string' as const)
}))
const outputs = Array.from({ length: 20 }, (_, i) => ({
name: `output_${i}`,
type: i % 2 === 0 ? 'number' : ('string' as const)
}))
const subgraph = createTestSubgraph({
name: 'Max IO Subgraph',
inputs,
outputs,
nodeCount: 10
})
await use(subgraph)
}
})
/**
* Helper to verify fixture integrity.
* Use this in tests to ensure fixtures are properly set up.
*/
export function verifyFixtureIntegrity<T extends Record<string, unknown>>(
fixture: T,
expectedProperties: (keyof T)[]
): void {
for (const prop of expectedProperties) {
if (!(prop in fixture)) {
throw new Error(`Fixture missing required property: ${String(prop)}`)
}
if (fixture[prop] === undefined || fixture[prop] === null) {
throw new Error(`Fixture property ${String(prop)} is null or undefined`)
}
}
}
/**
* Creates a snapshot-friendly representation of a subgraph for testing.
* Useful for serialization tests and regression detection.
*/
export function createSubgraphSnapshot(subgraph: Subgraph) {
return {
id: subgraph.id,
name: subgraph.name,
inputCount: subgraph.inputs.length,
outputCount: subgraph.outputs.length,
nodeCount: subgraph.nodes.length,
linkCount: subgraph.links.size,
inputs: subgraph.inputs.map((i) => ({ name: i.name, type: i.type })),
outputs: subgraph.outputs.map((o) => ({ name: o.name, type: o.type })),
hasInputNode: !!subgraph.inputNode,
hasOutputNode: !!subgraph.outputNode
}
}

View File

@@ -382,76 +382,6 @@ export function createTestSubgraphData(
}
}
/**
* Creates a complex subgraph with multiple nodes and connections.
* Useful for testing realistic scenarios.
* @param nodeCount Number of internal nodes to create
* @returns Complex subgraph data structure
*/
export function createComplexSubgraphData(
nodeCount: number = 5
): ExportedSubgraph {
const nodes = []
const links: Record<
string,
{
id: number
origin_id: number
origin_slot: number
target_id: number
target_slot: number
type: string
}
> = {}
// Create internal nodes
for (let i = 0; i < nodeCount; i++) {
nodes.push({
id: i + 1, // Start from 1 to avoid conflicts with IO nodes
type: 'basic/test',
pos: [100 + i * 150, 200],
size: [120, 60],
inputs: [{ name: 'in', type: '*', link: null }],
outputs: [{ name: 'out', type: '*', links: [] }],
properties: { value: i },
flags: {},
mode: 0
})
}
// Create some internal links
for (let i = 0; i < nodeCount - 1; i++) {
const linkId = i + 1
links[linkId] = {
id: linkId,
origin_id: i + 1,
origin_slot: 0,
target_id: i + 2,
target_slot: 0,
type: '*'
}
}
return createTestSubgraphData({
// @ts-expect-error TODO: Fix after merge - nodes parameter type
nodes,
// @ts-expect-error TODO: Fix after merge - links parameter type
links,
inputs: [
// @ts-expect-error TODO: Fix after merge - input object type
{ name: 'input1', type: 'number', pos: [0, 0] },
// @ts-expect-error TODO: Fix after merge - input object type
{ name: 'input2', type: 'string', pos: [0, 1] }
],
outputs: [
// @ts-expect-error TODO: Fix after merge - output object type
{ name: 'output1', type: 'number', pos: [0, 0] },
// @ts-expect-error TODO: Fix after merge - output object type
{ name: 'output2', type: 'string', pos: [0, 1] }
]
})
}
/**
* Creates an event capture system for testing event sequences.
* @param eventTarget The event target to monitor
@@ -493,39 +423,5 @@ export function createEventCapture<T = unknown>(
}
}
/**
* Utility to log subgraph structure for debugging tests.
* @param subgraph The subgraph to inspect
* @param label Optional label for the log output
*/
export function logSubgraphStructure(
subgraph: Subgraph,
label: string = 'Subgraph'
): void {
console.log(`\n=== ${label} Structure ===`)
console.log(`Name: ${subgraph.name}`)
console.log(`ID: ${subgraph.id}`)
console.log(`Inputs: ${subgraph.inputs.length}`)
console.log(`Outputs: ${subgraph.outputs.length}`)
console.log(`Nodes: ${subgraph.nodes.length}`)
console.log(`Links: ${subgraph.links.size}`)
if (subgraph.inputs.length > 0) {
console.log(
'Input details:',
subgraph.inputs.map((i) => ({ name: i.name, type: i.type }))
)
}
if (subgraph.outputs.length > 0) {
console.log(
'Output details:',
subgraph.outputs.map((o) => ({ name: o.name, type: o.type }))
)
}
console.log('========================\n')
}
// Re-export expect from vitest for convenience
export { expect } from 'vitest'

View File

@@ -0,0 +1,406 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { useSpatialIndex } from '@/composables/graph/useSpatialIndex'
import type { Bounds } from '@/utils/spatial/QuadTree'
// Skip this entire suite on CI to avoid flaky performance timing
const isCI = Boolean(process.env.CI)
const describeIfNotCI = isCI ? describe.skip : describe
describeIfNotCI('Spatial Index Performance', () => {
let spatialIndex: ReturnType<typeof useSpatialIndex>
beforeEach(() => {
spatialIndex = useSpatialIndex({
maxDepth: 6,
maxItemsPerNode: 4,
updateDebounceMs: 0 // Disable debouncing for tests
})
})
describe('large scale operations', () => {
it('should handle 1000 node insertions efficiently', () => {
const startTime = performance.now()
// Generate 1000 nodes in a realistic distribution
const nodes = Array.from({ length: 1000 }, (_, i) => ({
id: `node${i}`,
position: {
x: (Math.random() - 0.5) * 10000,
y: (Math.random() - 0.5) * 10000
},
size: {
width: 150 + Math.random() * 100,
height: 100 + Math.random() * 50
}
}))
spatialIndex.batchUpdate(nodes)
const insertTime = performance.now() - startTime
// Should insert 1000 nodes in under 100ms
expect(insertTime).toBeLessThan(100)
expect(spatialIndex.metrics.value.totalNodes).toBe(1000)
})
it('should maintain fast viewport queries under load', () => {
// First populate with many nodes
const nodes = Array.from({ length: 1000 }, (_, i) => ({
id: `node${i}`,
position: {
x: (Math.random() - 0.5) * 10000,
y: (Math.random() - 0.5) * 10000
},
size: { width: 200, height: 100 }
}))
spatialIndex.batchUpdate(nodes)
// Now benchmark viewport queries
const queryCount = 100
const viewportBounds: Bounds = {
x: -960,
y: -540,
width: 1920,
height: 1080
}
const startTime = performance.now()
for (let i = 0; i < queryCount; i++) {
// Vary viewport position to test different tree regions
const offsetX = (i % 10) * 500
const offsetY = Math.floor(i / 10) * 300
spatialIndex.queryViewport({
x: viewportBounds.x + offsetX,
y: viewportBounds.y + offsetY,
width: viewportBounds.width,
height: viewportBounds.height
})
}
const totalQueryTime = performance.now() - startTime
const avgQueryTime = totalQueryTime / queryCount
// Each query should take less than 2ms on average
expect(avgQueryTime).toBeLessThan(2)
expect(totalQueryTime).toBeLessThan(100) // 100 queries in under 100ms
})
it('should demonstrate performance advantage over linear search', () => {
// Create test data
const nodeCount = 500
const nodes = Array.from({ length: nodeCount }, (_, i) => ({
id: `node${i}`,
position: {
x: (Math.random() - 0.5) * 8000,
y: (Math.random() - 0.5) * 8000
},
size: { width: 200, height: 100 }
}))
// Populate spatial index
spatialIndex.batchUpdate(nodes)
const viewport: Bounds = { x: -500, y: -300, width: 1000, height: 600 }
const queryCount = 50
// Benchmark spatial index queries
const spatialStartTime = performance.now()
for (let i = 0; i < queryCount; i++) {
spatialIndex.queryViewport(viewport)
}
const spatialTime = performance.now() - spatialStartTime
// Benchmark linear search equivalent
const linearStartTime = performance.now()
for (let i = 0; i < queryCount; i++) {
nodes.filter((node) => {
const nodeRight = node.position.x + node.size.width
const nodeBottom = node.position.y + node.size.height
const viewportRight = viewport.x + viewport.width
const viewportBottom = viewport.y + viewport.height
return !(
nodeRight < viewport.x ||
node.position.x > viewportRight ||
nodeBottom < viewport.y ||
node.position.y > viewportBottom
)
})
}
const linearTime = performance.now() - linearStartTime
// Spatial index should be faster than linear search
const speedup = linearTime / spatialTime
// In some environments, speedup may be less due to small dataset
// Just ensure spatial is not significantly slower (at least 10% of linear speed)
expect(speedup).toBeGreaterThan(0.1)
// Both should find roughly the same number of nodes
const spatialResults = spatialIndex.queryViewport(viewport)
const linearResults = nodes.filter((node) => {
const nodeRight = node.position.x + node.size.width
const nodeBottom = node.position.y + node.size.height
const viewportRight = viewport.x + viewport.width
const viewportBottom = viewport.y + viewport.height
return !(
nodeRight < viewport.x ||
node.position.x > viewportRight ||
nodeBottom < viewport.y ||
node.position.y > viewportBottom
)
})
// Results should be similar (within 10% due to QuadTree boundary effects)
const resultsDiff = Math.abs(spatialResults.length - linearResults.length)
const maxDiff =
Math.max(spatialResults.length, linearResults.length) * 0.1
expect(resultsDiff).toBeLessThan(maxDiff)
})
})
describe('update performance', () => {
it('should handle frequent position updates efficiently', () => {
// Add initial nodes
const nodeCount = 200
const initialNodes = Array.from({ length: nodeCount }, (_, i) => ({
id: `node${i}`,
position: { x: i * 100, y: i * 50 },
size: { width: 200, height: 100 }
}))
spatialIndex.batchUpdate(initialNodes)
// Benchmark frequent updates (simulating animation/dragging)
const updateCount = 100
const startTime = performance.now()
for (let frame = 0; frame < updateCount; frame++) {
// Update a subset of nodes each frame
for (let i = 0; i < 20; i++) {
const nodeId = `node${i}`
spatialIndex.updateNode(
nodeId,
{
x: i * 100 + Math.sin(frame * 0.1) * 50,
y: i * 50 + Math.cos(frame * 0.1) * 30
},
{ width: 200, height: 100 }
)
}
}
const updateTime = performance.now() - startTime
const avgFrameTime = updateTime / updateCount
// Should maintain 60fps (16.67ms per frame) with 20 node updates per frame
expect(avgFrameTime).toBeLessThan(8) // Conservative target: 8ms per frame
})
it('should handle node additions and removals efficiently', () => {
const startTime = performance.now()
// Add nodes
for (let i = 0; i < 100; i++) {
spatialIndex.updateNode(
`node${i}`,
{ x: Math.random() * 1000, y: Math.random() * 1000 },
{ width: 200, height: 100 }
)
}
// Remove half of them
for (let i = 0; i < 50; i++) {
spatialIndex.removeNode(`node${i}`)
}
// Add new ones
for (let i = 100; i < 150; i++) {
spatialIndex.updateNode(
`node${i}`,
{ x: Math.random() * 1000, y: Math.random() * 1000 },
{ width: 200, height: 100 }
)
}
const totalTime = performance.now() - startTime
// All operations should complete quickly
expect(totalTime).toBeLessThan(50)
expect(spatialIndex.metrics.value.totalNodes).toBe(100) // 50 remaining + 50 new
})
})
describe('memory and scaling', () => {
it('should scale efficiently with increasing node counts', () => {
const nodeCounts = [100, 200, 500, 1000]
const queryTimes: number[] = []
for (const nodeCount of nodeCounts) {
// Create fresh spatial index for each test
const testIndex = useSpatialIndex({ updateDebounceMs: 0 })
// Populate with nodes
const nodes = Array.from({ length: nodeCount }, (_, i) => ({
id: `node${i}`,
position: {
x: (Math.random() - 0.5) * 10000,
y: (Math.random() - 0.5) * 10000
},
size: { width: 200, height: 100 }
}))
testIndex.batchUpdate(nodes)
// Benchmark query time
const viewport: Bounds = { x: -500, y: -300, width: 1000, height: 600 }
const startTime = performance.now()
for (let i = 0; i < 10; i++) {
testIndex.queryViewport(viewport)
}
const avgTime = (performance.now() - startTime) / 10
queryTimes.push(avgTime)
}
// Query time should scale logarithmically, not linearly
// The ratio between 1000 nodes and 100 nodes should be less than 5x
const ratio100to1000 = queryTimes[3] / queryTimes[0]
expect(ratio100to1000).toBeLessThan(5)
// All query times should be reasonable
queryTimes.forEach((time) => {
expect(time).toBeLessThan(5) // Each query under 5ms
})
})
it('should handle edge cases without performance degradation', () => {
// Test with very large nodes
spatialIndex.updateNode(
'huge-node',
{ x: -1000, y: -1000 },
{ width: 5000, height: 3000 }
)
// Test with many tiny nodes
for (let i = 0; i < 100; i++) {
spatialIndex.updateNode(
`tiny-${i}`,
{ x: Math.random() * 100, y: Math.random() * 100 },
{ width: 1, height: 1 }
)
}
// Test with nodes at extreme coordinates
spatialIndex.updateNode(
'extreme-pos',
{ x: 50000, y: -50000 },
{ width: 200, height: 100 }
)
spatialIndex.updateNode(
'extreme-neg',
{ x: -50000, y: 50000 },
{ width: 200, height: 100 }
)
// Queries should still be fast
const startTime = performance.now()
for (let i = 0; i < 20; i++) {
spatialIndex.queryViewport({
x: Math.random() * 1000 - 500,
y: Math.random() * 1000 - 500,
width: 1000,
height: 600
})
}
const queryTime = performance.now() - startTime
expect(queryTime).toBeLessThan(20) // 20 queries in under 20ms
})
})
describe('realistic workflow scenarios', () => {
it('should handle typical ComfyUI workflow performance', () => {
// Simulate a large ComfyUI workflow with clustered nodes
const clusters = [
{ center: { x: 0, y: 0 }, nodeCount: 50 },
{ center: { x: 2000, y: 0 }, nodeCount: 30 },
{ center: { x: 4000, y: 1000 }, nodeCount: 40 },
{ center: { x: 0, y: 2000 }, nodeCount: 35 }
]
let nodeId = 0
const allNodes: Array<{
id: string
position: { x: number; y: number }
size: { width: number; height: number }
}> = []
// Create clustered nodes (realistic for ComfyUI workflows)
clusters.forEach((cluster) => {
for (let i = 0; i < cluster.nodeCount; i++) {
allNodes.push({
id: `node${nodeId++}`,
position: {
x: cluster.center.x + (Math.random() - 0.5) * 800,
y: cluster.center.y + (Math.random() - 0.5) * 600
},
size: {
width: 150 + Math.random() * 100,
height: 100 + Math.random() * 50
}
})
}
})
// Add the nodes
const setupTime = performance.now()
spatialIndex.batchUpdate(allNodes)
const setupDuration = performance.now() - setupTime
// Simulate user panning around the workflow
const viewportSize = { width: 1920, height: 1080 }
const panPositions = [
{ x: -960, y: -540 }, // Center on first cluster
{ x: 1040, y: -540 }, // Pan to second cluster
{ x: 3040, y: 460 }, // Pan to third cluster
{ x: -960, y: 1460 }, // Pan to fourth cluster
{ x: 1000, y: 500 } // Overview position
]
const panStartTime = performance.now()
const queryResults: number[] = []
panPositions.forEach((pos) => {
// Simulate multiple viewport queries during smooth panning
for (let step = 0; step < 10; step++) {
const results = spatialIndex.queryViewport({
x: pos.x + step * 20,
y: pos.y + step * 10,
width: viewportSize.width,
height: viewportSize.height
})
queryResults.push(results.length)
}
})
const panDuration = performance.now() - panStartTime
const avgQueryTime = panDuration / (panPositions.length * 10)
// Performance expectations for realistic workflows
expect(setupDuration).toBeLessThan(30) // Setup 155 nodes in under 30ms
expect(avgQueryTime).toBeLessThan(1.5) // Average query under 1.5ms
expect(panDuration).toBeLessThan(50) // All panning queries under 50ms
// Should have reasonable culling efficiency
const totalNodes = allNodes.length
const avgVisibleNodes =
queryResults.reduce((a, b) => a + b, 0) / queryResults.length
const cullRatio = (totalNodes - avgVisibleNodes) / totalNodes
expect(cullRatio).toBeGreaterThan(0.3) // At least 30% culling efficiency
})
})
})

View File

@@ -0,0 +1,483 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { useTransformState } from '@/composables/element/useTransformState'
// Mock canvas context for testing
const createMockCanvasContext = () => ({
ds: {
offset: [0, 0] as [number, number],
scale: 1
}
})
// Skip this entire suite on CI to avoid flaky performance timing
const isCI = Boolean(process.env.CI)
const describeIfNotCI = isCI ? describe.skip : describe
describeIfNotCI('Transform Performance', () => {
let transformState: ReturnType<typeof useTransformState>
let mockCanvas: any
beforeEach(() => {
transformState = useTransformState()
mockCanvas = createMockCanvasContext()
})
describe('coordinate conversion performance', () => {
it('should handle large batches of coordinate conversions efficiently', () => {
// Set up a realistic transform state
mockCanvas.ds.offset = [500, 300]
mockCanvas.ds.scale = 1.5
transformState.syncWithCanvas(mockCanvas)
const conversionCount = 10000
const points = Array.from({ length: conversionCount }, () => ({
x: Math.random() * 5000,
y: Math.random() * 3000
}))
// Benchmark canvas to screen conversions
const canvasToScreenStart = performance.now()
const screenPoints = points.map((point) =>
transformState.canvasToScreen(point)
)
const canvasToScreenTime = performance.now() - canvasToScreenStart
// Benchmark screen to canvas conversions
const screenToCanvasStart = performance.now()
const backToCanvas = screenPoints.map((point) =>
transformState.screenToCanvas(point)
)
const screenToCanvasTime = performance.now() - screenToCanvasStart
// Performance expectations
expect(canvasToScreenTime).toBeLessThan(20) // 10k conversions in under 20ms
expect(screenToCanvasTime).toBeLessThan(20) // 10k conversions in under 20ms
// Verify accuracy of round-trip conversion
const maxError = points.reduce((max, original, i) => {
const converted = backToCanvas[i]
const errorX = Math.abs(original.x - converted.x)
const errorY = Math.abs(original.y - converted.y)
return Math.max(max, errorX, errorY)
}, 0)
expect(maxError).toBeLessThan(0.001) // Sub-pixel accuracy
})
it('should maintain performance across different zoom levels', () => {
const zoomLevels = [0.1, 0.5, 1.0, 2.0, 5.0, 10.0]
const conversionCount = 1000
const testPoints = Array.from({ length: conversionCount }, () => ({
x: Math.random() * 2000,
y: Math.random() * 1500
}))
const performanceResults: number[] = []
zoomLevels.forEach((scale) => {
mockCanvas.ds.scale = scale
transformState.syncWithCanvas(mockCanvas)
const startTime = performance.now()
testPoints.forEach((point) => {
const screen = transformState.canvasToScreen(point)
transformState.screenToCanvas(screen)
})
const duration = performance.now() - startTime
performanceResults.push(duration)
})
// Performance should be consistent across zoom levels
const maxTime = Math.max(...performanceResults)
const minTime = Math.min(...performanceResults)
const variance = (maxTime - minTime) / minTime
expect(maxTime).toBeLessThan(20) // All zoom levels under 20ms
expect(variance).toBeLessThan(3.0) // Less than 300% variance between zoom levels
})
it('should handle extreme coordinate values efficiently', () => {
// Test with very large coordinate values
const extremePoints = [
{ x: -100000, y: -100000 },
{ x: 100000, y: 100000 },
{ x: 0, y: 0 },
{ x: -50000, y: 50000 },
{ x: 1e6, y: -1e6 }
]
// Test at extreme zoom levels
const extremeScales = [0.001, 1000]
extremeScales.forEach((scale) => {
mockCanvas.ds.scale = scale
mockCanvas.ds.offset = [1000, 500]
transformState.syncWithCanvas(mockCanvas)
const startTime = performance.now()
// Convert each point 100 times
extremePoints.forEach((point) => {
for (let i = 0; i < 100; i++) {
const screen = transformState.canvasToScreen(point)
transformState.screenToCanvas(screen)
}
})
const duration = performance.now() - startTime
expect(duration).toBeLessThan(5) // Should handle extremes efficiently
expect(
Number.isFinite(transformState.canvasToScreen(extremePoints[0]).x)
).toBe(true)
expect(
Number.isFinite(transformState.canvasToScreen(extremePoints[0]).y)
).toBe(true)
})
})
})
describe('viewport culling performance', () => {
it('should efficiently determine node visibility for large numbers of nodes', () => {
// Set up realistic viewport
const viewport = { width: 1920, height: 1080 }
// Generate many node positions
const nodeCount = 1000
const nodes = Array.from({ length: nodeCount }, () => ({
pos: [Math.random() * 10000, Math.random() * 6000] as ArrayLike<number>,
size: [
150 + Math.random() * 100,
100 + Math.random() * 50
] as ArrayLike<number>
}))
// Test at different zoom levels and positions
const testConfigs = [
{ scale: 0.5, offset: [0, 0] },
{ scale: 1.0, offset: [2000, 1000] },
{ scale: 2.0, offset: [-1000, -500] }
]
testConfigs.forEach((config) => {
mockCanvas.ds.scale = config.scale
mockCanvas.ds.offset = config.offset
transformState.syncWithCanvas(mockCanvas)
const startTime = performance.now()
// Test viewport culling for all nodes
const visibleNodes = nodes.filter((node) =>
transformState.isNodeInViewport(node.pos, node.size, viewport)
)
const cullTime = performance.now() - startTime
expect(cullTime).toBeLessThan(10) // 1000 nodes culled in under 10ms
expect(visibleNodes.length).toBeLessThan(nodeCount) // Some culling should occur
expect(visibleNodes.length).toBeGreaterThanOrEqual(0) // Sanity check
})
})
it('should optimize culling with adaptive margins', () => {
const viewport = { width: 1280, height: 720 }
const testNode = {
pos: [1300, 100] as ArrayLike<number>, // Just outside viewport
size: [200, 100] as ArrayLike<number>
}
// Test margin adaptation at different zoom levels
const zoomTests = [
{ scale: 0.05, expectedVisible: true }, // Low zoom, larger margin
{ scale: 1.0, expectedVisible: true }, // Normal zoom, standard margin
{ scale: 4.0, expectedVisible: false } // High zoom, tighter margin
]
const marginTests: boolean[] = []
const timings: number[] = []
zoomTests.forEach((test) => {
mockCanvas.ds.scale = test.scale
mockCanvas.ds.offset = [0, 0]
transformState.syncWithCanvas(mockCanvas)
const startTime = performance.now()
const isVisible = transformState.isNodeInViewport(
testNode.pos,
testNode.size,
viewport,
0.2 // 20% margin
)
const duration = performance.now() - startTime
marginTests.push(isVisible)
timings.push(duration)
})
// All culling operations should be very fast
timings.forEach((time) => {
expect(time).toBeLessThan(0.1) // Individual culling under 0.1ms
})
// Verify adaptive behavior (margins should work as expected)
expect(marginTests[0]).toBe(zoomTests[0].expectedVisible)
expect(marginTests[2]).toBe(zoomTests[2].expectedVisible)
})
it('should handle size-based culling efficiently', () => {
// Test nodes of various sizes
const nodeSizes = [
[1, 1], // Tiny node
[5, 5], // Small node
[50, 50], // Medium node
[200, 100], // Large node
[500, 300] // Very large node
]
const viewport = { width: 1920, height: 1080 }
// Position all nodes in viewport center
const centerPos = [960, 540] as ArrayLike<number>
nodeSizes.forEach((size) => {
// Test at very low zoom where size culling should activate
mockCanvas.ds.scale = 0.01 // Very low zoom
transformState.syncWithCanvas(mockCanvas)
const startTime = performance.now()
const isVisible = transformState.isNodeInViewport(
centerPos,
size as ArrayLike<number>,
viewport
)
const cullTime = performance.now() - startTime
expect(cullTime).toBeLessThan(0.1) // Size culling under 0.1ms
// At 0.01 zoom, nodes need to be 400+ pixels to show as 4+ screen pixels
const screenSize = Math.max(size[0], size[1]) * 0.01
if (screenSize < 4) {
expect(isVisible).toBe(false)
} else {
expect(isVisible).toBe(true)
}
})
})
})
describe('transform state synchronization', () => {
it('should efficiently sync with canvas state changes', () => {
const syncCount = 1000
const transformUpdates = Array.from({ length: syncCount }, (_, i) => ({
offset: [Math.sin(i * 0.1) * 1000, Math.cos(i * 0.1) * 500],
scale: 0.5 + Math.sin(i * 0.05) * 0.4 // Scale between 0.1 and 0.9
}))
const startTime = performance.now()
transformUpdates.forEach((update) => {
mockCanvas.ds.offset = update.offset
mockCanvas.ds.scale = update.scale
transformState.syncWithCanvas(mockCanvas)
})
const syncTime = performance.now() - startTime
expect(syncTime).toBeLessThan(15) // 1000 syncs in under 15ms
// Verify final state is correct
const lastUpdate = transformUpdates[transformUpdates.length - 1]
expect(transformState.camera.x).toBe(lastUpdate.offset[0])
expect(transformState.camera.y).toBe(lastUpdate.offset[1])
expect(transformState.camera.z).toBe(lastUpdate.scale)
})
it('should generate CSS transform strings efficiently', () => {
const transformCount = 10000
// Set up varying transform states
const transforms = Array.from({ length: transformCount }, (_, i) => {
mockCanvas.ds.offset = [i * 10, i * 5]
mockCanvas.ds.scale = 0.5 + (i % 100) / 100
transformState.syncWithCanvas(mockCanvas)
return transformState.transformStyle.value
})
const startTime = performance.now()
// Access transform styles (triggers computed property)
transforms.forEach((style) => {
expect(style.transform).toContain('scale(')
expect(style.transform).toContain('translate(')
expect(style.transformOrigin).toBe('0 0')
})
const accessTime = performance.now() - startTime
expect(accessTime).toBeLessThan(200) // 10k style accesses in under 200ms
})
})
describe('bounds calculation performance', () => {
it('should calculate node screen bounds efficiently', () => {
// Set up realistic transform
mockCanvas.ds.offset = [200, 100]
mockCanvas.ds.scale = 1.5
transformState.syncWithCanvas(mockCanvas)
const nodeCount = 1000
const nodes = Array.from({ length: nodeCount }, () => ({
pos: [Math.random() * 5000, Math.random() * 3000] as ArrayLike<number>,
size: [
100 + Math.random() * 200,
80 + Math.random() * 120
] as ArrayLike<number>
}))
const startTime = performance.now()
const bounds = nodes.map((node) =>
transformState.getNodeScreenBounds(node.pos, node.size)
)
const calcTime = performance.now() - startTime
expect(calcTime).toBeLessThan(15) // 1000 bounds calculations in under 15ms
expect(bounds).toHaveLength(nodeCount)
// Verify bounds are reasonable
bounds.forEach((bound) => {
expect(bound.width).toBeGreaterThan(0)
expect(bound.height).toBeGreaterThan(0)
expect(Number.isFinite(bound.x)).toBe(true)
expect(Number.isFinite(bound.y)).toBe(true)
})
})
it('should calculate viewport bounds efficiently', () => {
const viewportSizes = [
{ width: 800, height: 600 },
{ width: 1920, height: 1080 },
{ width: 3840, height: 2160 },
{ width: 1280, height: 720 }
]
const margins = [0, 0.1, 0.2, 0.5]
const combinations = viewportSizes.flatMap((viewport) =>
margins.map((margin) => ({ viewport, margin }))
)
const startTime = performance.now()
const allBounds = combinations.map(({ viewport, margin }) => {
mockCanvas.ds.offset = [Math.random() * 1000, Math.random() * 500]
mockCanvas.ds.scale = 0.5 + Math.random() * 2
transformState.syncWithCanvas(mockCanvas)
return transformState.getViewportBounds(viewport, margin)
})
const calcTime = performance.now() - startTime
expect(calcTime).toBeLessThan(5) // All viewport calculations in under 5ms
expect(allBounds).toHaveLength(combinations.length)
// Verify bounds are reasonable
allBounds.forEach((bounds) => {
expect(bounds.width).toBeGreaterThan(0)
expect(bounds.height).toBeGreaterThan(0)
expect(Number.isFinite(bounds.x)).toBe(true)
expect(Number.isFinite(bounds.y)).toBe(true)
})
})
})
describe('real-world performance scenarios', () => {
it('should handle smooth panning performance', () => {
// Simulate smooth 60fps panning for 2 seconds
const frameCount = 120 // 2 seconds at 60fps
const panDistance = 2000 // Pan 2000 pixels
const frames: number[] = []
for (let frame = 0; frame < frameCount; frame++) {
const progress = frame / (frameCount - 1)
const x = progress * panDistance
const y = Math.sin(progress * Math.PI * 2) * 200 // Slight vertical wave
mockCanvas.ds.offset = [x, y]
const frameStart = performance.now()
// Typical operations during panning
transformState.syncWithCanvas(mockCanvas)
const style = transformState.transformStyle.value // Access transform style
expect(style.transform).toContain('translate') // Verify style is valid
// Simulate some coordinate conversions (mouse tracking, etc.)
for (let i = 0; i < 5; i++) {
const screen = transformState.canvasToScreen({
x: x + i * 100,
y: y + i * 50
})
transformState.screenToCanvas(screen)
}
const frameTime = performance.now() - frameStart
frames.push(frameTime)
// Each frame should be well under 16.67ms for 60fps
expect(frameTime).toBeLessThan(1) // Conservative: under 1ms per frame
}
const totalTime = frames.reduce((sum, time) => sum + time, 0)
const avgFrameTime = totalTime / frameCount
expect(avgFrameTime).toBeLessThan(0.5) // Average frame time under 0.5ms
expect(totalTime).toBeLessThan(60) // Total panning overhead under 60ms
})
it('should handle zoom performance with viewport updates', () => {
// Simulate smooth zoom from 0.1x to 10x
const zoomSteps = 100
const viewport = { width: 1920, height: 1080 }
const zoomTimes: number[] = []
for (let step = 0; step < zoomSteps; step++) {
const zoomLevel = Math.pow(10, (step / (zoomSteps - 1)) * 2 - 1) // 0.1 to 10
mockCanvas.ds.scale = zoomLevel
const stepStart = performance.now()
// Operations during zoom
transformState.syncWithCanvas(mockCanvas)
// Viewport bounds calculation (for culling)
transformState.getViewportBounds(viewport, 0.2)
// Test a few nodes for visibility
for (let i = 0; i < 10; i++) {
transformState.isNodeInViewport(
[i * 200, i * 150],
[200, 100],
viewport
)
}
const stepTime = performance.now() - stepStart
zoomTimes.push(stepTime)
}
const maxZoomTime = Math.max(...zoomTimes)
const avgZoomTime =
zoomTimes.reduce((sum, time) => sum + time, 0) / zoomSteps
expect(maxZoomTime).toBeLessThan(2) // No zoom step over 2ms
expect(avgZoomTime).toBeLessThan(1) // Average zoom step under 1ms
})
})
})

View File

@@ -0,0 +1,260 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource, type NodeLayout } from '@/renderer/core/layout/types'
describe('layoutStore CRDT operations', () => {
beforeEach(() => {
// Clear the store before each test
layoutStore.initializeFromLiteGraph([])
})
// Helper to create test node data
const createTestNode = (id: string): NodeLayout => ({
id,
position: { x: 100, y: 100 },
size: { width: 200, height: 100 },
zIndex: 0,
visible: true,
bounds: { x: 100, y: 100, width: 200, height: 100 }
})
it('should create and retrieve nodes', () => {
const nodeId = 'test-node-1'
const layout = createTestNode(nodeId)
// Create node
layoutStore.setSource(LayoutSource.External)
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
// Retrieve node
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
expect(nodeRef.value).toEqual(layout)
})
it('should move nodes', () => {
const nodeId = 'test-node-2'
const layout = createTestNode(nodeId)
// Create node first
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
// Move node
const newPosition = { x: 200, y: 300 }
layoutStore.applyOperation({
type: 'moveNode',
entity: 'node',
nodeId,
position: newPosition,
previousPosition: layout.position,
timestamp: Date.now(),
source: LayoutSource.Vue,
actor: 'test'
})
// Verify position updated
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
expect(nodeRef.value?.position).toEqual(newPosition)
})
it('should resize nodes', () => {
const nodeId = 'test-node-3'
const layout = createTestNode(nodeId)
// Create node
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
// Resize node
const newSize = { width: 300, height: 150 }
layoutStore.applyOperation({
type: 'resizeNode',
entity: 'node',
nodeId,
size: newSize,
previousSize: layout.size,
timestamp: Date.now(),
source: LayoutSource.Canvas,
actor: 'test'
})
// Verify size updated
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
expect(nodeRef.value?.size).toEqual(newSize)
})
it('should delete nodes', () => {
const nodeId = 'test-node-4'
const layout = createTestNode(nodeId)
// Create node
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
// Delete node
layoutStore.applyOperation({
type: 'deleteNode',
entity: 'node',
nodeId,
previousLayout: layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
// Verify node deleted
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
expect(nodeRef.value).toBeNull()
})
it('should handle source and actor tracking', async () => {
const nodeId = 'test-node-5'
const layout = createTestNode(nodeId)
// Set source and actor
layoutStore.setSource(LayoutSource.Vue)
layoutStore.setActor('user-123')
// Track change notifications AFTER setting source/actor
const changes: any[] = []
const unsubscribe = layoutStore.onChange((change) => {
changes.push(change)
})
// Create node
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
// Wait for async notification
await new Promise((resolve) => setTimeout(resolve, 50))
expect(changes.length).toBeGreaterThanOrEqual(1)
const lastChange = changes[changes.length - 1]
expect(lastChange.source).toBe('vue')
expect(lastChange.operation.actor).toBe('user-123')
unsubscribe()
})
it('should query nodes by spatial bounds', () => {
const nodes = [
{ id: 'node-a', position: { x: 0, y: 0 } },
{ id: 'node-b', position: { x: 100, y: 100 } },
{ id: 'node-c', position: { x: 250, y: 250 } }
]
// Create nodes with proper bounds
nodes.forEach(({ id, position }) => {
const layout: NodeLayout = {
...createTestNode(id),
position,
bounds: {
x: position.x,
y: position.y,
width: 200,
height: 100
}
}
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId: id,
layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
})
// Query nodes in bounds
const nodesInBounds = layoutStore.queryNodesInBounds({
x: 50,
y: 50,
width: 200,
height: 200
})
// node-a: (0,0) to (200,100) - overlaps with query bounds (50,50) to (250,250)
// node-b: (100,100) to (300,200) - overlaps with query bounds
// node-c: (250,250) to (450,350) - touches corner of query bounds
expect(nodesInBounds).toContain('node-a')
expect(nodesInBounds).toContain('node-b')
expect(nodesInBounds).toContain('node-c')
})
it('should maintain operation history', () => {
const nodeId = 'test-node-history'
const layout = createTestNode(nodeId)
const startTime = Date.now()
// Create node
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: startTime,
source: LayoutSource.External,
actor: 'test-actor'
})
// Move node
layoutStore.applyOperation({
type: 'moveNode',
entity: 'node',
nodeId,
position: { x: 150, y: 150 },
previousPosition: { x: 100, y: 100 },
timestamp: startTime + 100,
source: LayoutSource.Vue,
actor: 'test-actor'
})
// Get operations by actor
const operations = layoutStore.getOperationsByActor('test-actor')
expect(operations.length).toBeGreaterThanOrEqual(2)
expect(operations[0].type).toBe('createNode')
expect(operations[1].type).toBe('moveNode')
// Get operations since timestamp
const recentOps = layoutStore.getOperationsSince(startTime + 50)
expect(recentOps.length).toBeGreaterThanOrEqual(1)
expect(recentOps[0].type).toBe('moveNode')
})
})

View File

@@ -0,0 +1,270 @@
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import {
LODLevel,
LOD_THRESHOLDS,
supportsFeatureAtZoom,
useLOD
} from '@/renderer/extensions/vueNodes/lod/useLOD'
describe('useLOD', () => {
describe('LOD level detection', () => {
it('should return MINIMAL for zoom <= 0.4', () => {
const zoomRef = ref(0.4)
const { lodLevel } = useLOD(zoomRef)
expect(lodLevel.value).toBe(LODLevel.MINIMAL)
zoomRef.value = 0.2
expect(lodLevel.value).toBe(LODLevel.MINIMAL)
zoomRef.value = 0.1
expect(lodLevel.value).toBe(LODLevel.MINIMAL)
})
it('should return REDUCED for 0.4 < zoom <= 0.8', () => {
const zoomRef = ref(0.5)
const { lodLevel } = useLOD(zoomRef)
expect(lodLevel.value).toBe(LODLevel.REDUCED)
zoomRef.value = 0.6
expect(lodLevel.value).toBe(LODLevel.REDUCED)
zoomRef.value = 0.8
expect(lodLevel.value).toBe(LODLevel.REDUCED)
})
it('should return FULL for zoom > 0.8', () => {
const zoomRef = ref(0.9)
const { lodLevel } = useLOD(zoomRef)
expect(lodLevel.value).toBe(LODLevel.FULL)
zoomRef.value = 1.0
expect(lodLevel.value).toBe(LODLevel.FULL)
zoomRef.value = 2.5
expect(lodLevel.value).toBe(LODLevel.FULL)
})
it('should be reactive to zoom changes', () => {
const zoomRef = ref(0.2)
const { lodLevel } = useLOD(zoomRef)
expect(lodLevel.value).toBe(LODLevel.MINIMAL)
zoomRef.value = 0.6
expect(lodLevel.value).toBe(LODLevel.REDUCED)
zoomRef.value = 1.0
expect(lodLevel.value).toBe(LODLevel.FULL)
})
})
describe('rendering decisions', () => {
it('should disable all rendering for MINIMAL LOD', () => {
const zoomRef = ref(0.2)
const {
shouldRenderWidgets,
shouldRenderSlots,
shouldRenderContent,
shouldRenderSlotLabels,
shouldRenderWidgetLabels
} = useLOD(zoomRef)
expect(shouldRenderWidgets.value).toBe(false)
expect(shouldRenderSlots.value).toBe(false)
expect(shouldRenderContent.value).toBe(false)
expect(shouldRenderSlotLabels.value).toBe(false)
expect(shouldRenderWidgetLabels.value).toBe(false)
})
it('should enable widgets/slots but disable labels for REDUCED LOD', () => {
const zoomRef = ref(0.6)
const {
shouldRenderWidgets,
shouldRenderSlots,
shouldRenderContent,
shouldRenderSlotLabels,
shouldRenderWidgetLabels
} = useLOD(zoomRef)
expect(shouldRenderWidgets.value).toBe(true)
expect(shouldRenderSlots.value).toBe(true)
expect(shouldRenderContent.value).toBe(false)
expect(shouldRenderSlotLabels.value).toBe(false)
expect(shouldRenderWidgetLabels.value).toBe(false)
})
it('should enable all rendering for FULL LOD', () => {
const zoomRef = ref(1.0)
const {
shouldRenderWidgets,
shouldRenderSlots,
shouldRenderContent,
shouldRenderSlotLabels,
shouldRenderWidgetLabels
} = useLOD(zoomRef)
expect(shouldRenderWidgets.value).toBe(true)
expect(shouldRenderSlots.value).toBe(true)
expect(shouldRenderContent.value).toBe(true)
expect(shouldRenderSlotLabels.value).toBe(true)
expect(shouldRenderWidgetLabels.value).toBe(true)
})
})
describe('CSS classes', () => {
it('should return correct CSS class for each LOD level', () => {
const zoomRef = ref(0.2)
const { lodCssClass } = useLOD(zoomRef)
expect(lodCssClass.value).toBe('lg-node--lod-minimal')
zoomRef.value = 0.6
expect(lodCssClass.value).toBe('lg-node--lod-reduced')
zoomRef.value = 1.0
expect(lodCssClass.value).toBe('lg-node--lod-full')
})
})
describe('essential widgets filtering', () => {
it('should return all widgets for FULL LOD', () => {
const zoomRef = ref(1.0)
const { getEssentialWidgets } = useLOD(zoomRef)
const widgets = [
{ type: 'combo' },
{ type: 'text' },
{ type: 'button' },
{ type: 'slider' }
]
expect(getEssentialWidgets(widgets)).toEqual(widgets)
})
it('should return empty array for MINIMAL LOD', () => {
const zoomRef = ref(0.2)
const { getEssentialWidgets } = useLOD(zoomRef)
const widgets = [{ type: 'combo' }, { type: 'text' }, { type: 'button' }]
expect(getEssentialWidgets(widgets)).toEqual([])
})
it('should filter to essential types for REDUCED LOD', () => {
const zoomRef = ref(0.6)
const { getEssentialWidgets } = useLOD(zoomRef)
const widgets = [
{ type: 'combo' },
{ type: 'text' },
{ type: 'button' },
{ type: 'slider' },
{ type: 'toggle' },
{ type: 'number' }
]
const essential = getEssentialWidgets(widgets)
expect(essential).toHaveLength(4)
expect(essential.map((w: any) => w.type)).toEqual([
'combo',
'slider',
'toggle',
'number'
])
})
it('should handle case-insensitive widget types', () => {
const zoomRef = ref(0.6)
const { getEssentialWidgets } = useLOD(zoomRef)
const widgets = [
{ type: 'COMBO' },
{ type: 'Select' },
{ type: 'TOGGLE' }
]
const essential = getEssentialWidgets(widgets)
expect(essential).toHaveLength(3)
})
it('should handle widgets with undefined or missing type', () => {
const zoomRef = ref(0.6)
const { getEssentialWidgets } = useLOD(zoomRef)
const widgets = [
{ type: 'combo' },
{ type: undefined },
{},
{ type: 'slider' }
]
const essential = getEssentialWidgets(widgets)
expect(essential).toHaveLength(2)
expect(essential.map((w: any) => w.type)).toEqual(['combo', 'slider'])
})
})
describe('performance metrics', () => {
it('should provide debug metrics', () => {
const zoomRef = ref(0.6)
const { lodMetrics } = useLOD(zoomRef)
expect(lodMetrics.value).toEqual({
level: LODLevel.REDUCED,
zoom: 0.6,
widgetCount: 'full',
slotCount: 'full'
})
})
it('should update metrics when zoom changes', () => {
const zoomRef = ref(0.2)
const { lodMetrics } = useLOD(zoomRef)
expect(lodMetrics.value.level).toBe(LODLevel.MINIMAL)
expect(lodMetrics.value.widgetCount).toBe('none')
expect(lodMetrics.value.slotCount).toBe('none')
zoomRef.value = 1.0
expect(lodMetrics.value.level).toBe(LODLevel.FULL)
expect(lodMetrics.value.widgetCount).toBe('full')
expect(lodMetrics.value.slotCount).toBe('full')
})
})
})
describe('LOD_THRESHOLDS', () => {
it('should export correct threshold values', () => {
expect(LOD_THRESHOLDS.FULL_THRESHOLD).toBe(0.8)
expect(LOD_THRESHOLDS.REDUCED_THRESHOLD).toBe(0.4)
expect(LOD_THRESHOLDS.MINIMAL_THRESHOLD).toBe(0.0)
})
})
describe('supportsFeatureAtZoom', () => {
it('should return correct feature support for different zoom levels', () => {
expect(supportsFeatureAtZoom(1.0, 'renderWidgets')).toBe(true)
expect(supportsFeatureAtZoom(1.0, 'renderSlots')).toBe(true)
expect(supportsFeatureAtZoom(1.0, 'renderContent')).toBe(true)
expect(supportsFeatureAtZoom(0.6, 'renderWidgets')).toBe(true)
expect(supportsFeatureAtZoom(0.6, 'renderSlots')).toBe(true)
expect(supportsFeatureAtZoom(0.6, 'renderContent')).toBe(false)
expect(supportsFeatureAtZoom(0.2, 'renderWidgets')).toBe(false)
expect(supportsFeatureAtZoom(0.2, 'renderSlots')).toBe(false)
expect(supportsFeatureAtZoom(0.2, 'renderContent')).toBe(false)
})
it('should handle threshold boundary values correctly', () => {
expect(supportsFeatureAtZoom(0.8, 'renderWidgets')).toBe(true)
expect(supportsFeatureAtZoom(0.8, 'renderContent')).toBe(false)
expect(supportsFeatureAtZoom(0.81, 'renderContent')).toBe(true)
expect(supportsFeatureAtZoom(0.4, 'renderWidgets')).toBe(false)
expect(supportsFeatureAtZoom(0.41, 'renderWidgets')).toBe(true)
})
})

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useComboWidget } from '@/composables/widgets/useComboWidget'
import { useComboWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useComboWidget'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
vi.mock('@/scripts/widgets', () => ({

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { _for_testing } from '@/composables/widgets/useFloatWidget'
import { _for_testing } from '@/renderer/extensions/vueNodes/widgets/composables/useFloatWidget'
vi.mock('@/scripts/widgets', () => ({
addValueControlWidgets: vi.fn()

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { _for_testing } from '@/composables/widgets/useIntWidget'
import { _for_testing } from '@/renderer/extensions/vueNodes/widgets/composables/useIntWidget'
vi.mock('@/scripts/widgets', () => ({
addValueControlWidgets: vi.fn()

View File

@@ -1,7 +1,7 @@
import axios from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useRemoteWidget } from '@/composables/widgets/useRemoteWidget'
import { useRemoteWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget'
import { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
vi.mock('axios', () => {

View File

@@ -0,0 +1,168 @@
import { describe, expect, it } from 'vitest'
import WidgetButton from '@/renderer/extensions/vueNodes/widgets/components/WidgetButton.vue'
import WidgetColorPicker from '@/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue'
import WidgetFileUpload from '@/renderer/extensions/vueNodes/widgets/components/WidgetFileUpload.vue'
import WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue'
import WidgetMarkdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.vue'
import WidgetSelect from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue'
import WidgetSlider from '@/renderer/extensions/vueNodes/widgets/components/WidgetSlider.vue'
import WidgetTextarea from '@/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue'
import WidgetToggleSwitch from '@/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.vue'
import {
getComponent,
isEssential,
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
describe('widgetRegistry', () => {
describe('getComponent', () => {
// Test number type mappings
describe('number types', () => {
it('should map int types to slider widget', () => {
expect(getComponent('int')).toBe(WidgetSlider)
expect(getComponent('INT')).toBe(WidgetSlider)
})
it('should map float types to slider widget', () => {
expect(getComponent('float')).toBe(WidgetSlider)
expect(getComponent('FLOAT')).toBe(WidgetSlider)
expect(getComponent('number')).toBe(WidgetSlider)
expect(getComponent('slider')).toBe(WidgetSlider)
})
})
// Test text type mappings
describe('text types', () => {
it('should map text variations to input text widget', () => {
expect(getComponent('text')).toBe(WidgetInputText)
expect(getComponent('string')).toBe(WidgetInputText)
expect(getComponent('STRING')).toBe(WidgetInputText)
})
it('should map multiline text types to textarea widget', () => {
expect(getComponent('multiline')).toBe(WidgetTextarea)
expect(getComponent('textarea')).toBe(WidgetTextarea)
expect(getComponent('TEXTAREA')).toBe(WidgetTextarea)
expect(getComponent('customtext')).toBe(WidgetTextarea)
})
it('should map markdown to markdown widget', () => {
expect(getComponent('MARKDOWN')).toBe(WidgetMarkdown)
expect(getComponent('markdown')).toBe(WidgetMarkdown)
})
})
// Test selection type mappings
describe('selection types', () => {
it('should map combo types to select widget', () => {
expect(getComponent('combo')).toBe(WidgetSelect)
expect(getComponent('COMBO')).toBe(WidgetSelect)
})
})
// Test boolean type mappings
describe('boolean types', () => {
it('should map boolean types to toggle switch widget', () => {
expect(getComponent('toggle')).toBe(WidgetToggleSwitch)
expect(getComponent('boolean')).toBe(WidgetToggleSwitch)
expect(getComponent('BOOLEAN')).toBe(WidgetToggleSwitch)
})
})
// Test advanced widget mappings
describe('advanced widgets', () => {
it('should map color types to color picker widget', () => {
expect(getComponent('color')).toBe(WidgetColorPicker)
expect(getComponent('COLOR')).toBe(WidgetColorPicker)
})
it('should map file types to file upload widget', () => {
expect(getComponent('file')).toBe(WidgetFileUpload)
expect(getComponent('fileupload')).toBe(WidgetFileUpload)
expect(getComponent('FILEUPLOAD')).toBe(WidgetFileUpload)
})
it('should map button types to button widget', () => {
expect(getComponent('button')).toBe(WidgetButton)
expect(getComponent('BUTTON')).toBe(WidgetButton)
})
})
// Test fallback behavior
describe('fallback behavior', () => {
it('should return null for unknown types', () => {
expect(getComponent('unknown')).toBe(null)
expect(getComponent('custom_widget')).toBe(null)
expect(getComponent('')).toBe(null)
})
})
})
describe('shouldRenderAsVue', () => {
it('should return false for widgets marked as canvas-only', () => {
const widget = { type: 'text', options: { canvasOnly: true } }
expect(shouldRenderAsVue(widget)).toBe(false)
})
it('should return false for widgets without a type', () => {
const widget = { options: {} }
expect(shouldRenderAsVue(widget)).toBe(false)
})
it('should return true for widgets with mapped types', () => {
expect(shouldRenderAsVue({ type: 'text' })).toBe(true)
expect(shouldRenderAsVue({ type: 'int' })).toBe(true)
expect(shouldRenderAsVue({ type: 'combo' })).toBe(true)
})
it('should return false for unknown types', () => {
expect(shouldRenderAsVue({ type: 'unknown_type' })).toBe(false)
})
it('should respect options while checking type', () => {
const widget = { type: 'text', options: { someOption: 'value' } }
expect(shouldRenderAsVue(widget)).toBe(true)
})
})
describe('isEssential', () => {
it('should identify essential widget types', () => {
expect(isEssential('int')).toBe(true)
expect(isEssential('INT')).toBe(true)
expect(isEssential('float')).toBe(true)
expect(isEssential('FLOAT')).toBe(true)
expect(isEssential('boolean')).toBe(true)
expect(isEssential('BOOLEAN')).toBe(true)
expect(isEssential('combo')).toBe(true)
expect(isEssential('COMBO')).toBe(true)
})
it('should identify non-essential widget types', () => {
expect(isEssential('button')).toBe(false)
expect(isEssential('color')).toBe(false)
expect(isEssential('chart')).toBe(false)
expect(isEssential('fileupload')).toBe(false)
})
it('should return false for unknown types', () => {
expect(isEssential('unknown')).toBe(false)
expect(isEssential('')).toBe(false)
})
})
describe('edge cases', () => {
it('should handle widgets with empty options', () => {
const widget = { type: 'text', options: {} }
expect(shouldRenderAsVue(widget)).toBe(true)
})
it('should handle case sensitivity correctly through aliases', () => {
// Test that both lowercase and uppercase work
expect(getComponent('string')).toBe(WidgetInputText)
expect(getComponent('STRING')).toBe(WidgetInputText)
expect(getComponent('combo')).toBe(WidgetSelect)
expect(getComponent('COMBO')).toBe(WidgetSelect)
})
})
})

View File

@@ -4,15 +4,47 @@ import { nextTick, ref } from 'vue'
import { useComfyManagerService } from '@/services/comfyManagerService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import {
InstalledPacksResponse,
ManagerPackInstalled
} from '@/types/comfyManagerTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
type InstalledPacksResponse =
ManagerComponents['schemas']['InstalledPacksResponse']
type ManagerChannel = ManagerComponents['schemas']['ManagerChannel']
type ManagerDatabaseSource =
ManagerComponents['schemas']['ManagerDatabaseSource']
type ManagerPackInstalled = ManagerComponents['schemas']['ManagerPackInstalled']
vi.mock('@/services/comfyManagerService', () => ({
useComfyManagerService: vi.fn()
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showManagerProgressDialog: vi.fn()
})
}))
vi.mock('@/composables/useManagerQueue', () => {
const enqueueTaskMock = vi.fn()
return {
useManagerQueue: () => ({
statusMessage: ref(''),
allTasksDone: ref(false),
enqueueTask: enqueueTaskMock,
isProcessingTasks: ref(false)
}),
enqueueTask: enqueueTaskMock
}
})
vi.mock('@/composables/useServerLogs', () => ({
useServerLogs: () => ({
startListening: vi.fn(),
stopListening: vi.fn(),
logs: ref([])
})
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: vi.fn((key) => key)
@@ -33,11 +65,7 @@ interface EnabledDisabledTestCase {
}
describe('useComfyManagerStore', () => {
let mockManagerService: {
isLoading: ReturnType<typeof ref<boolean>>
error: ReturnType<typeof ref<string | null>>
listInstalledPacks: ReturnType<typeof vi.fn>
}
let mockManagerService: ReturnType<typeof useComfyManagerService>
const triggerPacksChange = async (
installedPacks: InstalledPacksResponse,
@@ -55,10 +83,22 @@ describe('useComfyManagerStore', () => {
mockManagerService = {
isLoading: ref(false),
error: ref(null),
listInstalledPacks: vi.fn().mockResolvedValue({})
startQueue: vi.fn().mockResolvedValue(null),
getQueueStatus: vi.fn().mockResolvedValue(null),
getTaskHistory: vi.fn().mockResolvedValue(null),
listInstalledPacks: vi.fn().mockResolvedValue({}),
getImportFailInfo: vi.fn().mockResolvedValue(null),
getImportFailInfoBulk: vi.fn().mockResolvedValue({}),
installPack: vi.fn().mockResolvedValue(null),
uninstallPack: vi.fn().mockResolvedValue(null),
enablePack: vi.fn().mockResolvedValue(null),
disablePack: vi.fn().mockResolvedValue(null),
updatePack: vi.fn().mockResolvedValue(null),
updateAllPacks: vi.fn().mockResolvedValue(null),
rebootComfyUI: vi.fn().mockResolvedValue(null),
isLegacyManagerUI: vi.fn().mockResolvedValue(false)
}
// @ts-expect-error Mocking the return type of useComfyManagerService
vi.mocked(useComfyManagerService).mockReturnValue(mockManagerService)
})
@@ -313,4 +353,90 @@ describe('useComfyManagerStore', () => {
}
)
})
describe.skip('isPackInstalling', () => {
it('should return false for packs not being installed', () => {
const store = useComfyManagerStore()
expect(store.isPackInstalling('test-pack')).toBe(false)
expect(store.isPackInstalling(undefined)).toBe(false)
expect(store.isPackInstalling('')).toBe(false)
})
it('should track pack as installing when installPack is called', async () => {
const store = useComfyManagerStore()
// Call installPack
await store.installPack.call({
id: 'test-pack',
repository: 'https://github.com/test/test-pack',
channel: 'dev' as ManagerChannel,
mode: 'cache' as ManagerDatabaseSource,
selected_version: 'latest',
version: 'latest'
})
// Check that the pack is marked as installing
expect(store.isPackInstalling('test-pack')).toBe(true)
})
it('should remove pack from installing list when explicitly removed', async () => {
const store = useComfyManagerStore()
// Call installPack
await store.installPack.call({
id: 'test-pack',
repository: 'https://github.com/test/test-pack',
channel: 'dev' as ManagerChannel,
mode: 'cache' as ManagerDatabaseSource,
selected_version: 'latest',
version: 'latest'
})
// Verify pack is installing
expect(store.isPackInstalling('test-pack')).toBe(true)
// Call installPack again for another pack to demonstrate multiple installs
await store.installPack.call({
id: 'another-pack',
repository: 'https://github.com/test/another-pack',
channel: 'dev' as ManagerChannel,
mode: 'cache' as ManagerDatabaseSource,
selected_version: 'latest',
version: 'latest'
})
// Both should be installing
expect(store.isPackInstalling('test-pack')).toBe(true)
expect(store.isPackInstalling('another-pack')).toBe(true)
})
it('should track multiple packs installing independently', async () => {
const store = useComfyManagerStore()
// Install pack 1
await store.installPack.call({
id: 'pack-1',
repository: 'https://github.com/test/pack-1',
channel: 'dev' as ManagerChannel,
mode: 'cache' as ManagerDatabaseSource,
selected_version: 'latest',
version: 'latest'
})
// Install pack 2
await store.installPack.call({
id: 'pack-2',
repository: 'https://github.com/test/pack-2',
channel: 'dev' as ManagerChannel,
mode: 'cache' as ManagerDatabaseSource,
selected_version: 'latest',
version: 'latest'
})
// Both should be installing
expect(store.isPackInstalling('pack-1')).toBe(true)
expect(store.isPackInstalling('pack-2')).toBe(true)
expect(store.isPackInstalling('pack-3')).toBe(false)
})
})
})

View File

@@ -0,0 +1,307 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import {
ModelNodeProvider,
useModelToNodeStore
} from '@/stores/modelToNodeStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
const EXPECTED_DEFAULT_TYPES = [
'checkpoints',
'loras',
'vae',
'controlnet',
'unet',
'upscale_models',
'style_models',
'gligen'
] as const
type NodeDefStoreType = typeof import('@/stores/nodeDefStore')
// Mock nodeDefStore dependency - modelToNodeStore relies on this for registration
// Most tests expect this to be populated; tests that need empty state can override
vi.mock('@/stores/nodeDefStore', async (importOriginal) => {
const original = await importOriginal<NodeDefStoreType>()
const { ComfyNodeDefImpl } = original
// Create minimal but valid ComfyNodeDefImpl for testing
function createMockNodeDef(name: string): ComfyNodeDefImpl {
const def: ComfyNodeDefV1 = {
name,
display_name: name,
category: 'test',
python_module: 'nodes',
description: '',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
output_node: false
}
return new ComfyNodeDefImpl(def)
}
const MOCK_NODE_NAMES = [
'CheckpointLoaderSimple',
'ImageOnlyCheckpointLoader',
'LoraLoader',
'LoraLoaderModelOnly',
'VAELoader',
'ControlNetLoader',
'UNETLoader',
'UpscaleModelLoader',
'StyleModelLoader',
'GLIGENLoader'
] as const
const mockNodeDefsByName = Object.fromEntries(
MOCK_NODE_NAMES.map((name) => [name, createMockNodeDef(name)])
)
return {
...original,
useNodeDefStore: vi.fn(() => ({
nodeDefsByName: mockNodeDefsByName
}))
}
})
describe('useModelToNodeStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('modelToNodeMap', () => {
it('should initialize as empty', () => {
const modelToNodeStore = useModelToNodeStore()
expect(Object.keys(modelToNodeStore.modelToNodeMap)).toHaveLength(0)
})
it('should populate after registration', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
expect(Object.keys(modelToNodeStore.modelToNodeMap)).toEqual(
expect.arrayContaining(['checkpoints', 'unet'])
)
})
})
describe('getNodeProvider', () => {
it('should return provider for registered model type', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
const provider = modelToNodeStore.getNodeProvider('checkpoints')
expect(provider).toBeDefined()
// After asserting provider is defined, we can safely access its properties
expect(provider?.nodeDef?.name).toBe('CheckpointLoaderSimple')
expect(provider?.key).toBe('ckpt_name')
})
it('should return undefined for unregistered model type', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
expect(modelToNodeStore.getNodeProvider('nonexistent')).toBeUndefined()
})
it('should return first registered provider when multiple providers exist for same model type', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
const provider = modelToNodeStore.getNodeProvider('checkpoints')
// Using optional chaining for safety since getNodeProvider() can return undefined
expect(provider?.nodeDef?.name).toBe('CheckpointLoaderSimple')
})
it('should trigger lazy registration when called before registerDefaults', () => {
const modelToNodeStore = useModelToNodeStore()
const provider = modelToNodeStore.getNodeProvider('checkpoints')
expect(provider).toBeDefined()
})
})
describe('getAllNodeProviders', () => {
it('should return all providers for model type with multiple nodes', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
const checkpointProviders =
modelToNodeStore.getAllNodeProviders('checkpoints')
expect(checkpointProviders).toHaveLength(2)
expect(checkpointProviders).toEqual(
expect.arrayContaining([
expect.objectContaining({
nodeDef: expect.objectContaining({ name: 'CheckpointLoaderSimple' })
}),
expect.objectContaining({
nodeDef: expect.objectContaining({
name: 'ImageOnlyCheckpointLoader'
})
})
])
)
const loraProviders = modelToNodeStore.getAllNodeProviders('loras')
expect(loraProviders).toHaveLength(2)
})
it('should return single provider for model type with one node', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
const unetProviders = modelToNodeStore.getAllNodeProviders('unet')
expect(unetProviders).toHaveLength(1)
expect(unetProviders[0].nodeDef.name).toBe('UNETLoader')
})
it('should return empty array for unregistered model type', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
expect(modelToNodeStore.getAllNodeProviders('nonexistent')).toEqual([])
})
it('should trigger lazy registration when called before registerDefaults', () => {
const modelToNodeStore = useModelToNodeStore()
const providers = modelToNodeStore.getAllNodeProviders('checkpoints')
expect(providers.length).toBeGreaterThan(0)
})
})
describe('registerNodeProvider', () => {
it('should register provider directly', () => {
const modelToNodeStore = useModelToNodeStore()
const nodeDefStore = useNodeDefStore()
const customProvider = new ModelNodeProvider(
nodeDefStore.nodeDefsByName['UNETLoader'],
'custom_key'
)
modelToNodeStore.registerNodeProvider('custom_type', customProvider)
const retrieved = modelToNodeStore.getNodeProvider('custom_type')
expect(retrieved).toStrictEqual(customProvider)
// Optional chaining for consistency with getNodeProvider() return type
expect(retrieved?.key).toBe('custom_key')
})
it('should handle multiple providers for same model type and return first as primary', () => {
const modelToNodeStore = useModelToNodeStore()
const nodeDefStore = useNodeDefStore()
const provider1 = new ModelNodeProvider(
nodeDefStore.nodeDefsByName['UNETLoader'],
'key1'
)
const provider2 = new ModelNodeProvider(
nodeDefStore.nodeDefsByName['VAELoader'],
'key2'
)
modelToNodeStore.registerNodeProvider('multi_type', provider1)
modelToNodeStore.registerNodeProvider('multi_type', provider2)
const allProviders = modelToNodeStore.getAllNodeProviders('multi_type')
expect(allProviders).toHaveLength(2)
expect(modelToNodeStore.getNodeProvider('multi_type')).toStrictEqual(
provider1
)
})
it('should initialize new model type when first provider is registered', () => {
const modelToNodeStore = useModelToNodeStore()
const nodeDefStore = useNodeDefStore()
expect(modelToNodeStore.modelToNodeMap['new_type']).toBeUndefined()
const provider = new ModelNodeProvider(
nodeDefStore.nodeDefsByName['UNETLoader'],
'test_key'
)
modelToNodeStore.registerNodeProvider('new_type', provider)
expect(modelToNodeStore.modelToNodeMap['new_type']).toBeDefined()
expect(modelToNodeStore.modelToNodeMap['new_type']).toHaveLength(1)
})
})
describe('quickRegister', () => {
it('should connect node class to model type with parameter mapping', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.quickRegister('test_type', 'UNETLoader', 'test_param')
const provider = modelToNodeStore.getNodeProvider('test_type')
expect(provider).toBeDefined()
// After asserting provider is defined, we can safely access its properties
expect(provider!.nodeDef.name).toBe('UNETLoader')
expect(provider!.key).toBe('test_param')
})
it('should handle registration of non-existent node classes gracefully', () => {
const modelToNodeStore = useModelToNodeStore()
expect(() => {
modelToNodeStore.quickRegister(
'test_type',
'NonExistentLoader',
'test_param'
)
}).not.toThrow()
const provider = modelToNodeStore.getNodeProvider('test_type')
// Optional chaining needed since getNodeProvider() can return undefined
expect(provider?.nodeDef).toBeUndefined()
})
it('should allow multiple node classes for same model type', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.quickRegister('multi_type', 'UNETLoader', 'param1')
modelToNodeStore.quickRegister('multi_type', 'VAELoader', 'param2')
const providers = modelToNodeStore.getAllNodeProviders('multi_type')
expect(providers).toHaveLength(2)
})
})
describe('registerDefaults integration', () => {
it('should register all expected model types based on mock data', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
for (const modelType of EXPECTED_DEFAULT_TYPES) {
expect.soft(modelToNodeStore.getNodeProvider(modelType)).toBeDefined()
}
})
it('should be idempotent', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
const firstCheckpointCount =
modelToNodeStore.getAllNodeProviders('checkpoints').length
modelToNodeStore.registerDefaults() // Call again
const secondCheckpointCount =
modelToNodeStore.getAllNodeProviders('checkpoints').length
expect(secondCheckpointCount).toBe(firstCheckpointCount)
})
it('should not register when nodeDefStore is empty', () => {
vi.mocked(useNodeDefStore, { partial: true }).mockReturnValue({
nodeDefsByName: {}
})
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
expect(modelToNodeStore.getNodeProvider('checkpoints')).toBeUndefined()
})
})
describe('edge cases', () => {
it('should handle empty string model type', () => {
const modelToNodeStore = useModelToNodeStore()
expect(modelToNodeStore.getNodeProvider('')).toBeUndefined()
expect(modelToNodeStore.getAllNodeProviders('')).toEqual([])
})
})
})

View File

@@ -0,0 +1,271 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
describe('useConflictDetectionStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
const mockConflictedPackages: ConflictDetectionResult[] = [
{
package_id: 'ComfyUI-Manager',
package_name: 'ComfyUI-Manager',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'pending',
current_value: 'no_registry_data',
required_value: 'registry_data_available'
}
]
},
{
package_id: 'comfyui-easy-use',
package_name: 'comfyui-easy-use',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'comfyui_version',
current_value: '0.3.43',
required_value: '<0.3.40'
}
]
},
{
package_id: 'img2colors-comfyui-node',
package_name: 'img2colors-comfyui-node',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'banned',
current_value: 'installed',
required_value: 'not_banned'
}
]
}
]
describe('initial state', () => {
it('should have empty initial state', () => {
const store = useConflictDetectionStore()
expect(store.conflictedPackages).toEqual([])
expect(store.isDetecting).toBe(false)
expect(store.lastDetectionTime).toBeNull()
expect(store.hasConflicts).toBe(false)
})
})
describe('setConflictedPackages', () => {
it('should set conflicted packages', () => {
const store = useConflictDetectionStore()
store.setConflictedPackages(mockConflictedPackages)
expect(store.conflictedPackages).toEqual(mockConflictedPackages)
expect(store.conflictedPackages).toHaveLength(3)
})
it('should update hasConflicts computed property', () => {
const store = useConflictDetectionStore()
expect(store.hasConflicts).toBe(false)
store.setConflictedPackages(mockConflictedPackages)
expect(store.hasConflicts).toBe(true)
})
})
describe('getConflictsForPackageByID', () => {
it('should find package by exact ID match', () => {
const store = useConflictDetectionStore()
store.setConflictedPackages(mockConflictedPackages)
const result = store.getConflictsForPackageByID('ComfyUI-Manager')
expect(result).toBeDefined()
expect(result?.package_id).toBe('ComfyUI-Manager')
expect(result?.conflicts).toHaveLength(1)
})
it('should return undefined for non-existent package', () => {
const store = useConflictDetectionStore()
store.setConflictedPackages(mockConflictedPackages)
const result = store.getConflictsForPackageByID('non-existent-package')
expect(result).toBeUndefined()
})
})
describe('bannedPackages', () => {
it('should filter packages with banned conflicts', () => {
const store = useConflictDetectionStore()
store.setConflictedPackages(mockConflictedPackages)
const bannedPackages = store.bannedPackages
expect(bannedPackages).toHaveLength(1)
expect(bannedPackages[0].package_id).toBe('img2colors-comfyui-node')
})
it('should return empty array when no banned packages', () => {
const store = useConflictDetectionStore()
const noBannedPackages = mockConflictedPackages.filter(
(pkg) => !pkg.conflicts.some((c) => c.type === 'banned')
)
store.setConflictedPackages(noBannedPackages)
const bannedPackages = store.bannedPackages
expect(bannedPackages).toHaveLength(0)
})
})
describe('securityPendingPackages', () => {
it('should filter packages with pending conflicts', () => {
const store = useConflictDetectionStore()
store.setConflictedPackages(mockConflictedPackages)
const securityPendingPackages = store.securityPendingPackages
expect(securityPendingPackages).toHaveLength(1)
expect(securityPendingPackages[0].package_id).toBe('ComfyUI-Manager')
})
})
describe('clearConflicts', () => {
it('should clear all conflicted packages', () => {
const store = useConflictDetectionStore()
store.setConflictedPackages(mockConflictedPackages)
expect(store.conflictedPackages).toHaveLength(3)
expect(store.hasConflicts).toBe(true)
store.clearConflicts()
expect(store.conflictedPackages).toEqual([])
expect(store.hasConflicts).toBe(false)
})
})
describe('detection state management', () => {
it('should set detecting state', () => {
const store = useConflictDetectionStore()
expect(store.isDetecting).toBe(false)
store.setDetecting(true)
expect(store.isDetecting).toBe(true)
store.setDetecting(false)
expect(store.isDetecting).toBe(false)
})
it('should set last detection time', () => {
const store = useConflictDetectionStore()
const timestamp = '2024-01-01T00:00:00Z'
expect(store.lastDetectionTime).toBeNull()
store.setLastDetectionTime(timestamp)
expect(store.lastDetectionTime).toBe(timestamp)
})
})
describe('reactivity', () => {
it('should update computed properties when conflicted packages change', () => {
const store = useConflictDetectionStore()
// Initially no conflicts
expect(store.hasConflicts).toBe(false)
expect(store.bannedPackages).toHaveLength(0)
// Add conflicts
store.setConflictedPackages(mockConflictedPackages)
// Computed properties should update
expect(store.hasConflicts).toBe(true)
expect(store.bannedPackages).toHaveLength(1)
expect(store.securityPendingPackages).toHaveLength(1)
// Clear conflicts
store.clearConflicts()
// Computed properties should update again
expect(store.hasConflicts).toBe(false)
expect(store.bannedPackages).toHaveLength(0)
expect(store.securityPendingPackages).toHaveLength(0)
})
})
describe('edge cases', () => {
it('should handle empty conflicts array', () => {
const store = useConflictDetectionStore()
store.setConflictedPackages([])
expect(store.conflictedPackages).toEqual([])
expect(store.hasConflicts).toBe(false)
expect(store.bannedPackages).toHaveLength(0)
expect(store.securityPendingPackages).toHaveLength(0)
})
it('should handle packages with multiple conflict types', () => {
const store = useConflictDetectionStore()
const multiConflictPackage: ConflictDetectionResult = {
package_id: 'multi-conflict-package',
package_name: 'Multi Conflict Package',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'banned',
current_value: 'installed',
required_value: 'not_banned'
},
{
type: 'pending',
current_value: 'no_registry_data',
required_value: 'registry_data_available'
}
]
}
store.setConflictedPackages([multiConflictPackage])
// Should appear in both banned and security pending
expect(store.bannedPackages).toHaveLength(1)
expect(store.securityPendingPackages).toHaveLength(1)
expect(store.bannedPackages[0].package_id).toBe('multi-conflict-package')
expect(store.securityPendingPackages[0].package_id).toBe(
'multi-conflict-package'
)
})
it('should handle packages with has_conflict false', () => {
const store = useConflictDetectionStore()
const noConflictPackage: ConflictDetectionResult = {
package_id: 'no-conflict-package',
package_name: 'No Conflict Package',
has_conflict: false,
is_compatible: true,
conflicts: []
}
store.setConflictedPackages([noConflictPackage])
// hasConflicts should check has_conflict property
expect(store.hasConflicts).toBe(false)
})
})
})

View File

@@ -0,0 +1,194 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { api } from '@/scripts/api'
import { useExtensionStore } from '@/stores/extensionStore'
import {
ManagerUIState,
useManagerStateStore
} from '@/stores/managerStateStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
// Mock dependencies
vi.mock('@/scripts/api', () => ({
api: {
getClientFeatureFlags: vi.fn(),
getServerFeature: vi.fn()
}
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: vi.fn(() => ({
flags: { supportsManagerV4: false },
featureFlag: vi.fn()
}))
}))
vi.mock('@/stores/extensionStore', () => ({
useExtensionStore: vi.fn()
}))
vi.mock('@/stores/systemStatsStore', () => ({
useSystemStatsStore: vi.fn()
}))
describe('useManagerStateStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('managerUIState computed', () => {
it('should return DISABLED state when --disable-manager is present', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: {
system: { argv: ['python', 'main.py', '--disable-manager'] }
}
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const store = useManagerStateStore()
expect(store.managerUIState).toBe(ManagerUIState.DISABLED)
})
it('should return LEGACY_UI state when --enable-manager-legacy-ui is present', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: {
system: { argv: ['python', 'main.py', '--enable-manager-legacy-ui'] }
}
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const store = useManagerStateStore()
expect(store.managerUIState).toBe(ManagerUIState.LEGACY_UI)
})
it('should return NEW_UI state when client and server both support v4', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: { system: { argv: ['python', 'main.py'] } }
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: true },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const store = useManagerStateStore()
expect(store.managerUIState).toBe(ManagerUIState.NEW_UI)
})
it('should return LEGACY_UI state when server supports v4 but client does not', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: { system: { argv: ['python', 'main.py'] } }
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: false
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: true },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const store = useManagerStateStore()
expect(store.managerUIState).toBe(ManagerUIState.LEGACY_UI)
})
it('should return LEGACY_UI state when legacy manager extension exists', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: { system: { argv: ['python', 'main.py'] } }
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: false },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: [{ name: 'Comfy.CustomNodesManager' }]
} as any)
const store = useManagerStateStore()
expect(store.managerUIState).toBe(ManagerUIState.LEGACY_UI)
})
it('should return DISABLED state when feature flags are undefined', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: { system: { argv: ['python', 'main.py'] } }
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(api.getServerFeature).mockReturnValue(undefined)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: undefined },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const store = useManagerStateStore()
expect(store.managerUIState).toBe(ManagerUIState.DISABLED)
})
it('should return DISABLED state when no manager is available', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: { system: { argv: ['python', 'main.py'] } }
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(api.getServerFeature).mockReturnValue(false)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: false },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const store = useManagerStateStore()
expect(store.managerUIState).toBe(ManagerUIState.DISABLED)
})
it('should handle null systemStats gracefully', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: null
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: true },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const store = useManagerStateStore()
expect(store.managerUIState).toBe(ManagerUIState.NEW_UI)
})
})
})

View File

@@ -0,0 +1,269 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { type Bounds, QuadTree } from '@/utils/spatial/QuadTree'
describe('QuadTree', () => {
let quadTree: QuadTree<string>
const worldBounds: Bounds = { x: 0, y: 0, width: 1000, height: 1000 }
beforeEach(() => {
quadTree = new QuadTree<string>(worldBounds, {
maxDepth: 4,
maxItemsPerNode: 4
})
})
describe('insertion', () => {
it('should insert items within bounds', () => {
const success = quadTree.insert(
'node1',
{ x: 100, y: 100, width: 50, height: 50 },
'node1'
)
expect(success).toBe(true)
expect(quadTree.size).toBe(1)
})
it('should reject items outside bounds', () => {
const success = quadTree.insert(
'node1',
{ x: -100, y: -100, width: 50, height: 50 },
'node1'
)
expect(success).toBe(false)
expect(quadTree.size).toBe(0)
})
it('should handle duplicate IDs by replacing', () => {
quadTree.insert(
'node1',
{ x: 100, y: 100, width: 50, height: 50 },
'data1'
)
quadTree.insert(
'node1',
{ x: 200, y: 200, width: 50, height: 50 },
'data2'
)
expect(quadTree.size).toBe(1)
const results = quadTree.query({
x: 150,
y: 150,
width: 100,
height: 100
})
expect(results).toContain('data2')
expect(results).not.toContain('data1')
})
})
describe('querying', () => {
beforeEach(() => {
// Insert test nodes in a grid pattern
for (let x = 0; x < 10; x++) {
for (let y = 0; y < 10; y++) {
const id = `node_${x}_${y}`
quadTree.insert(
id,
{
x: x * 100,
y: y * 100,
width: 50,
height: 50
},
id
)
}
}
})
it('should find nodes within query bounds', () => {
const results = quadTree.query({ x: 0, y: 0, width: 250, height: 250 })
expect(results.length).toBe(9) // 3x3 grid
})
it('should return empty array for out-of-bounds query', () => {
const results = quadTree.query({
x: 2000,
y: 2000,
width: 100,
height: 100
})
expect(results.length).toBe(0)
})
it('should handle partial overlaps', () => {
const results = quadTree.query({ x: 25, y: 25, width: 100, height: 100 })
expect(results.length).toBe(4) // 2x2 grid due to overlap
})
it('should handle large query areas efficiently', () => {
const startTime = performance.now()
const results = quadTree.query({ x: 0, y: 0, width: 1000, height: 1000 })
const queryTime = performance.now() - startTime
expect(results.length).toBe(100) // All nodes
expect(queryTime).toBeLessThan(5) // Should be fast
})
})
describe('removal', () => {
it('should remove existing items', () => {
quadTree.insert(
'node1',
{ x: 100, y: 100, width: 50, height: 50 },
'node1'
)
expect(quadTree.size).toBe(1)
const success = quadTree.remove('node1')
expect(success).toBe(true)
expect(quadTree.size).toBe(0)
})
it('should handle removal of non-existent items', () => {
const success = quadTree.remove('nonexistent')
expect(success).toBe(false)
})
})
describe('updating', () => {
it('should update item position', () => {
quadTree.insert(
'node1',
{ x: 100, y: 100, width: 50, height: 50 },
'node1'
)
const success = quadTree.update('node1', {
x: 200,
y: 200,
width: 50,
height: 50
})
expect(success).toBe(true)
// Should not find at old position
const oldResults = quadTree.query({
x: 75,
y: 75,
width: 100,
height: 100
})
expect(oldResults).not.toContain('node1')
// Should find at new position
const newResults = quadTree.query({
x: 175,
y: 175,
width: 100,
height: 100
})
expect(newResults).toContain('node1')
})
})
describe('subdivision', () => {
it('should subdivide when exceeding max items', () => {
// Insert 5 items (max is 4) to trigger subdivision
for (let i = 0; i < 5; i++) {
quadTree.insert(
`node${i}`,
{
x: i * 10,
y: i * 10,
width: 5,
height: 5
},
`node${i}`
)
}
expect(quadTree.size).toBe(5)
// Verify all items can still be found
const allResults = quadTree.query(worldBounds)
expect(allResults.length).toBe(5)
})
})
describe('performance', () => {
it('should handle 1000 nodes efficiently', () => {
const insertStart = performance.now()
// Insert 1000 nodes
for (let i = 0; i < 1000; i++) {
const x = Math.random() * 900
const y = Math.random() * 900
quadTree.insert(
`node${i}`,
{
x,
y,
width: 50,
height: 50
},
`node${i}`
)
}
const insertTime = performance.now() - insertStart
expect(insertTime).toBeLessThan(50) // Should be fast
// Query performance
const queryStart = performance.now()
const results = quadTree.query({
x: 400,
y: 400,
width: 200,
height: 200
})
const queryTime = performance.now() - queryStart
expect(queryTime).toBeLessThan(2) // Queries should be very fast
expect(results.length).toBeGreaterThan(0)
expect(results.length).toBeLessThan(1000) // Should cull most nodes
})
})
describe('edge cases', () => {
it('should handle zero-sized bounds', () => {
const success = quadTree.insert(
'point',
{ x: 100, y: 100, width: 0, height: 0 },
'point'
)
expect(success).toBe(true)
const results = quadTree.query({ x: 99, y: 99, width: 2, height: 2 })
expect(results).toContain('point')
})
it('should handle items spanning multiple quadrants', () => {
const success = quadTree.insert(
'large',
{
x: 400,
y: 400,
width: 200,
height: 200
},
'large'
)
expect(success).toBe(true)
// Should be found when querying any overlapping quadrant
const topLeft = quadTree.query({ x: 0, y: 0, width: 500, height: 500 })
const bottomRight = quadTree.query({
x: 500,
y: 500,
width: 500,
height: 500
})
expect(topLeft).toContain('large')
expect(bottomRight).toContain('large')
})
})
})