mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-28 10:12:11 +00:00
Merge remote-tracking branch 'origin/main' into feat/new-workflow-templates
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
433
tests-ui/tests/components/helpcenter/WhatsNewPopup.test.ts
Normal file
433
tests-ui/tests/components/helpcenter/WhatsNewPopup.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
351
tests-ui/tests/composables/element/useTransformState.test.ts
Normal file
351
tests-ui/tests/composables/element/useTransformState.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
240
tests-ui/tests/composables/graph/useCanvasTransformSync.test.ts
Normal file
240
tests-ui/tests/composables/graph/useCanvasTransformSync.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
498
tests-ui/tests/composables/graph/useSpatialIndex.test.ts
Normal file
498
tests-ui/tests/composables/graph/useSpatialIndex.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
277
tests-ui/tests/composables/graph/useTransformSettling.test.ts
Normal file
277
tests-ui/tests/composables/graph/useTransformSettling.test.ts
Normal 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 })
|
||||
)
|
||||
})
|
||||
})
|
||||
503
tests-ui/tests/composables/graph/useWidgetValue.test.ts
Normal file
503
tests-ui/tests/composables/graph/useWidgetValue.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
378
tests-ui/tests/composables/nodePack/usePacksSelection.test.ts
Normal file
378
tests-ui/tests/composables/nodePack/usePacksSelection.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
384
tests-ui/tests/composables/nodePack/usePacksStatus.test.ts
Normal file
384
tests-ui/tests/composables/nodePack/usePacksStatus.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
186
tests-ui/tests/composables/useConflictAcknowledgment.test.ts
Normal file
186
tests-ui/tests/composables/useConflictAcknowledgment.test.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
1006
tests-ui/tests/composables/useConflictDetection.test.ts
Normal file
1006
tests-ui/tests/composables/useConflictDetection.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
137
tests-ui/tests/composables/useFeatureFlags.test.ts
Normal file
137
tests-ui/tests/composables/useFeatureFlags.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
198
tests-ui/tests/composables/useImportFailedDetection.test.ts
Normal file
198
tests-ui/tests/composables/useImportFailedDetection.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
165
tests-ui/tests/composables/useManagerQueue.test.ts
Normal file
165
tests-ui/tests/composables/useManagerQueue.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 = {
|
||||
|
||||
360
tests-ui/tests/composables/useUpdateAvailableNodes.test.ts
Normal file
360
tests-ui/tests/composables/useUpdateAvailableNodes.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
406
tests-ui/tests/performance/spatialIndexPerformance.test.ts
Normal file
406
tests-ui/tests/performance/spatialIndexPerformance.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
483
tests-ui/tests/performance/transformPerformance.test.ts
Normal file
483
tests-ui/tests/performance/transformPerformance.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
260
tests-ui/tests/renderer/core/layout/layoutStore.test.ts
Normal file
260
tests-ui/tests/renderer/core/layout/layoutStore.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
270
tests-ui/tests/renderer/extensions/vueNodes/lod/useLOD.test.ts
Normal file
270
tests-ui/tests/renderer/extensions/vueNodes/lod/useLOD.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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', () => ({
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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', () => {
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
307
tests-ui/tests/store/modelToNodeStore.test.ts
Normal file
307
tests-ui/tests/store/modelToNodeStore.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
271
tests-ui/tests/stores/conflictDetectionStore.test.ts
Normal file
271
tests-ui/tests/stores/conflictDetectionStore.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
194
tests-ui/tests/stores/managerStateStore.test.ts
Normal file
194
tests-ui/tests/stores/managerStateStore.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
269
tests-ui/tests/utils/spatial/QuadTree.test.ts
Normal file
269
tests-ui/tests/utils/spatial/QuadTree.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user