mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-26 01:09:46 +00:00
[feat] TransformPane - Viewport synchronization layer for Vue nodes (#4304)
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Benjamin Lu <benceruleanlu@proton.me> Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
committed by
Benjamin Lu
parent
d488e59a2a
commit
2ab4fb79ee
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)
|
||||
})
|
||||
})
|
||||
270
tests-ui/tests/composables/graph/useLOD.test.ts
Normal file
270
tests-ui/tests/composables/graph/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 '@/composables/graph/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)
|
||||
})
|
||||
})
|
||||
483
tests-ui/tests/composables/graph/useSpatialIndex.test.ts
Normal file
483
tests-ui/tests/composables/graph/useSpatialIndex.test.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useSpatialIndex } from '@/composables/graph/useSpatialIndex'
|
||||
|
||||
import { createBounds } from '../../helpers/nodeTestHelpers'
|
||||
|
||||
// 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 = createBounds(0, 0, 5000, 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(createBounds(50, 50, 300, 200))
|
||||
expect(oldResults).not.toContain('node1')
|
||||
|
||||
// Query new position - should find node
|
||||
const newResults = queryViewport(createBounds(450, 450, 300, 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(createBounds(-50, -50, 200, 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(createBounds(-50, -50, 400, 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(createBounds(1000, 1000, 100, 100))
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it('should update metrics after query', () => {
|
||||
const { queryViewport, metrics } = spatialIndex
|
||||
|
||||
queryViewport(createBounds(0, 0, 300, 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(createBounds(0, 0, 100, 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(createBounds(0, 0, 500, 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(createBounds(-50, -50, 100, 100))
|
||||
expect(oldResults).not.toContain('old1')
|
||||
|
||||
// New nodes should be findable
|
||||
const newResults = queryViewport(createBounds(50, 50, 200, 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('getDebugVisualization', () => {
|
||||
it('should return null when debug is disabled', () => {
|
||||
const { getDebugVisualization } = spatialIndex
|
||||
|
||||
expect(getDebugVisualization()).toBeNull()
|
||||
})
|
||||
|
||||
it('should return debug info when enabled', () => {
|
||||
const debugIndex = useSpatialIndex({ enableDebugVisualization: true })
|
||||
debugIndex.initialize()
|
||||
|
||||
const debug = debugIndex.getDebugVisualization()
|
||||
expect(debug).not.toBeNull()
|
||||
expect(debug).toHaveProperty('size')
|
||||
expect(debug).toHaveProperty('tree')
|
||||
})
|
||||
})
|
||||
|
||||
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(createBounds(-50, -50, 200, 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(createBounds(50, 50, 100, 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(createBounds(-600, -600, 200, 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(createBounds(100, 100, 10, 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 })
|
||||
)
|
||||
})
|
||||
})
|
||||
141
tests-ui/tests/composables/graph/useWidgetRenderer.test.ts
Normal file
141
tests-ui/tests/composables/graph/useWidgetRenderer.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { WidgetType } from '@/components/graph/vueWidgets/widgetRegistry'
|
||||
import { useWidgetRenderer } from '@/composables/graph/useWidgetRenderer'
|
||||
|
||||
describe('useWidgetRenderer', () => {
|
||||
const { getWidgetComponent, shouldRenderAsVue } = useWidgetRenderer()
|
||||
|
||||
describe('getWidgetComponent', () => {
|
||||
// Test number type mappings
|
||||
describe('number types', () => {
|
||||
it('should map number type to NUMBER widget', () => {
|
||||
expect(getWidgetComponent('number')).toBe(WidgetType.NUMBER)
|
||||
})
|
||||
|
||||
it('should map slider type to SLIDER widget', () => {
|
||||
expect(getWidgetComponent('slider')).toBe(WidgetType.SLIDER)
|
||||
})
|
||||
|
||||
it('should map INT type to INT widget', () => {
|
||||
expect(getWidgetComponent('INT')).toBe(WidgetType.INT)
|
||||
})
|
||||
|
||||
it('should map FLOAT type to FLOAT widget', () => {
|
||||
expect(getWidgetComponent('FLOAT')).toBe(WidgetType.FLOAT)
|
||||
})
|
||||
})
|
||||
|
||||
// Test text type mappings
|
||||
describe('text types', () => {
|
||||
it('should map text variations to STRING widget', () => {
|
||||
expect(getWidgetComponent('text')).toBe(WidgetType.STRING)
|
||||
expect(getWidgetComponent('string')).toBe(WidgetType.STRING)
|
||||
expect(getWidgetComponent('STRING')).toBe(WidgetType.STRING)
|
||||
})
|
||||
|
||||
it('should map multiline text types to TEXTAREA widget', () => {
|
||||
expect(getWidgetComponent('multiline')).toBe(WidgetType.TEXTAREA)
|
||||
expect(getWidgetComponent('textarea')).toBe(WidgetType.TEXTAREA)
|
||||
expect(getWidgetComponent('MARKDOWN')).toBe(WidgetType.MARKDOWN)
|
||||
expect(getWidgetComponent('customtext')).toBe(WidgetType.TEXTAREA)
|
||||
})
|
||||
})
|
||||
|
||||
// Test selection type mappings
|
||||
describe('selection types', () => {
|
||||
it('should map combo types to COMBO widget', () => {
|
||||
expect(getWidgetComponent('combo')).toBe(WidgetType.COMBO)
|
||||
expect(getWidgetComponent('COMBO')).toBe(WidgetType.COMBO)
|
||||
})
|
||||
})
|
||||
|
||||
// Test boolean type mappings
|
||||
describe('boolean types', () => {
|
||||
it('should map boolean types to appropriate widgets', () => {
|
||||
expect(getWidgetComponent('toggle')).toBe(WidgetType.TOGGLESWITCH)
|
||||
expect(getWidgetComponent('boolean')).toBe(WidgetType.BOOLEAN)
|
||||
expect(getWidgetComponent('BOOLEAN')).toBe(WidgetType.BOOLEAN)
|
||||
})
|
||||
})
|
||||
|
||||
// Test advanced widget mappings
|
||||
describe('advanced widgets', () => {
|
||||
it('should map color types to COLOR widget', () => {
|
||||
expect(getWidgetComponent('color')).toBe(WidgetType.COLOR)
|
||||
expect(getWidgetComponent('COLOR')).toBe(WidgetType.COLOR)
|
||||
})
|
||||
|
||||
it('should map image types to IMAGE widget', () => {
|
||||
expect(getWidgetComponent('image')).toBe(WidgetType.IMAGE)
|
||||
expect(getWidgetComponent('IMAGE')).toBe(WidgetType.IMAGE)
|
||||
})
|
||||
|
||||
it('should map file types to FILEUPLOAD widget', () => {
|
||||
expect(getWidgetComponent('file')).toBe(WidgetType.FILEUPLOAD)
|
||||
expect(getWidgetComponent('FILEUPLOAD')).toBe(WidgetType.FILEUPLOAD)
|
||||
})
|
||||
|
||||
it('should map button types to BUTTON widget', () => {
|
||||
expect(getWidgetComponent('button')).toBe(WidgetType.BUTTON)
|
||||
expect(getWidgetComponent('BUTTON')).toBe(WidgetType.BUTTON)
|
||||
})
|
||||
})
|
||||
|
||||
// Test fallback behavior
|
||||
describe('fallback behavior', () => {
|
||||
it('should return STRING widget for unknown types', () => {
|
||||
expect(getWidgetComponent('unknown')).toBe(WidgetType.STRING)
|
||||
expect(getWidgetComponent('custom_widget')).toBe(WidgetType.STRING)
|
||||
expect(getWidgetComponent('')).toBe(WidgetType.STRING)
|
||||
})
|
||||
|
||||
it('should return STRING widget for unmapped but valid types', () => {
|
||||
expect(getWidgetComponent('datetime')).toBe(WidgetType.STRING)
|
||||
expect(getWidgetComponent('json')).toBe(WidgetType.STRING)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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: 'number' })).toBe(true)
|
||||
expect(shouldRenderAsVue({ type: 'combo' })).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true even for unknown types (fallback to STRING)', () => {
|
||||
expect(shouldRenderAsVue({ type: 'unknown_type' })).toBe(true)
|
||||
})
|
||||
|
||||
it('should respect options while checking type', () => {
|
||||
const widget = { type: 'text', options: { someOption: 'value' } }
|
||||
expect(shouldRenderAsVue(widget)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
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', () => {
|
||||
// Test that both lowercase and uppercase work
|
||||
expect(getWidgetComponent('string')).toBe(WidgetType.STRING)
|
||||
expect(getWidgetComponent('STRING')).toBe(WidgetType.STRING)
|
||||
expect(getWidgetComponent('combo')).toBe(WidgetType.COMBO)
|
||||
expect(getWidgetComponent('COMBO')).toBe(WidgetType.COMBO)
|
||||
})
|
||||
})
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user