mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-14 09:27:41 +00:00
[test] Relocate and update test files
- Move TransformPane tests from .test.ts to .spec.ts format - Relocate useWidgetValue tests to tests-ui directory - Move QuadTree tests to tests-ui/tests/utils/spatial directory - Add comprehensive tests for new composables: - useCanvasTransformSync tests - useTransformSettling tests - Update test imports and paths Tests now follow consistent organization patterns and cover the refactored transform sync functionality.
This commit is contained in:
239
tests-ui/tests/composables/graph/useCanvasTransformSync.test.ts
Normal file
239
tests-ui/tests/composables/graph/useCanvasTransformSync.test.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import type { LGraphCanvas } from '@comfyorg/litegraph'
|
||||
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'
|
||||
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
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 })
|
||||
)
|
||||
})
|
||||
})
|
||||
502
tests-ui/tests/composables/graph/useWidgetValue.test.ts
Normal file
502
tests-ui/tests/composables/graph/useWidgetValue.test.ts
Normal file
@@ -0,0 +1,502 @@
|
||||
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')
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
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