mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-03 20:51:58 +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
6e04cb72b0
commit
19084e2799
329
tests-ui/tests/composables/element/useTransformState.test.ts
Normal file
329
tests-ui/tests/composables/element/useTransformState.test.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useTransformState } from '@/composables/element/useTransformState'
|
||||
|
||||
import { createMockCanvasContext } from '../../helpers/nodeTestHelpers'
|
||||
|
||||
describe('useTransformState', () => {
|
||||
let transformState: ReturnType<typeof useTransformState>
|
||||
|
||||
beforeEach(() => {
|
||||
transformState = useTransformState()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should initialize with default camera values', () => {
|
||||
const { camera } = transformState
|
||||
expect(camera.x).toBe(0)
|
||||
expect(camera.y).toBe(0)
|
||||
expect(camera.z).toBe(1)
|
||||
})
|
||||
|
||||
it('should generate correct initial transform style', () => {
|
||||
const { transformStyle } = transformState
|
||||
expect(transformStyle.value).toEqual({
|
||||
transform: 'scale(1) translate(0px, 0px)',
|
||||
transformOrigin: '0 0'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('syncWithCanvas', () => {
|
||||
it('should sync camera state with canvas transform', () => {
|
||||
const { syncWithCanvas, camera } = transformState
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
|
||||
// Set mock canvas transform
|
||||
mockCanvas.ds.offset = [100, 50]
|
||||
mockCanvas.ds.scale = 2
|
||||
|
||||
syncWithCanvas(mockCanvas as any)
|
||||
|
||||
expect(camera.x).toBe(100)
|
||||
expect(camera.y).toBe(50)
|
||||
expect(camera.z).toBe(2)
|
||||
})
|
||||
|
||||
it('should handle null canvas gracefully', () => {
|
||||
const { syncWithCanvas, camera } = transformState
|
||||
|
||||
syncWithCanvas(null as any)
|
||||
|
||||
// Should remain at initial values
|
||||
expect(camera.x).toBe(0)
|
||||
expect(camera.y).toBe(0)
|
||||
expect(camera.z).toBe(1)
|
||||
})
|
||||
|
||||
it('should handle canvas without ds property', () => {
|
||||
const { syncWithCanvas, camera } = transformState
|
||||
const canvasWithoutDs = { canvas: {} }
|
||||
|
||||
syncWithCanvas(canvasWithoutDs as any)
|
||||
|
||||
// Should remain at initial values
|
||||
expect(camera.x).toBe(0)
|
||||
expect(camera.y).toBe(0)
|
||||
expect(camera.z).toBe(1)
|
||||
})
|
||||
|
||||
it('should update transform style after sync', () => {
|
||||
const { syncWithCanvas, transformStyle } = transformState
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
|
||||
mockCanvas.ds.offset = [150, 75]
|
||||
mockCanvas.ds.scale = 0.5
|
||||
|
||||
syncWithCanvas(mockCanvas as any)
|
||||
|
||||
expect(transformStyle.value).toEqual({
|
||||
transform: 'scale(0.5) translate(150px, 75px)',
|
||||
transformOrigin: '0 0'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('coordinate conversions', () => {
|
||||
beforeEach(() => {
|
||||
// Set up a known transform state
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
mockCanvas.ds.offset = [100, 50]
|
||||
mockCanvas.ds.scale = 2
|
||||
transformState.syncWithCanvas(mockCanvas as any)
|
||||
})
|
||||
|
||||
describe('canvasToScreen', () => {
|
||||
it('should convert canvas coordinates to screen coordinates', () => {
|
||||
const { canvasToScreen } = transformState
|
||||
|
||||
const canvasPoint = { x: 10, y: 20 }
|
||||
const screenPoint = canvasToScreen(canvasPoint)
|
||||
|
||||
// screen = canvas * scale + offset
|
||||
// x: 10 * 2 + 100 = 120
|
||||
// y: 20 * 2 + 50 = 90
|
||||
expect(screenPoint).toEqual({ x: 120, y: 90 })
|
||||
})
|
||||
|
||||
it('should handle zero coordinates', () => {
|
||||
const { canvasToScreen } = transformState
|
||||
|
||||
const screenPoint = canvasToScreen({ x: 0, y: 0 })
|
||||
expect(screenPoint).toEqual({ x: 100, y: 50 })
|
||||
})
|
||||
|
||||
it('should handle negative coordinates', () => {
|
||||
const { canvasToScreen } = transformState
|
||||
|
||||
const screenPoint = canvasToScreen({ x: -10, y: -20 })
|
||||
expect(screenPoint).toEqual({ x: 80, y: 10 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('screenToCanvas', () => {
|
||||
it('should convert screen coordinates to canvas coordinates', () => {
|
||||
const { screenToCanvas } = transformState
|
||||
|
||||
const screenPoint = { x: 120, y: 90 }
|
||||
const canvasPoint = screenToCanvas(screenPoint)
|
||||
|
||||
// canvas = (screen - offset) / scale
|
||||
// x: (120 - 100) / 2 = 10
|
||||
// y: (90 - 50) / 2 = 20
|
||||
expect(canvasPoint).toEqual({ x: 10, y: 20 })
|
||||
})
|
||||
|
||||
it('should be inverse of canvasToScreen', () => {
|
||||
const { canvasToScreen, screenToCanvas } = transformState
|
||||
|
||||
const originalPoint = { x: 25, y: 35 }
|
||||
const screenPoint = canvasToScreen(originalPoint)
|
||||
const backToCanvas = screenToCanvas(screenPoint)
|
||||
|
||||
expect(backToCanvas.x).toBeCloseTo(originalPoint.x)
|
||||
expect(backToCanvas.y).toBeCloseTo(originalPoint.y)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodeScreenBounds', () => {
|
||||
beforeEach(() => {
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
mockCanvas.ds.offset = [100, 50]
|
||||
mockCanvas.ds.scale = 2
|
||||
transformState.syncWithCanvas(mockCanvas as any)
|
||||
})
|
||||
|
||||
it('should calculate correct screen bounds for a node', () => {
|
||||
const { getNodeScreenBounds } = transformState
|
||||
|
||||
const nodePos = [10, 20]
|
||||
const nodeSize = [200, 100]
|
||||
const bounds = getNodeScreenBounds(nodePos, nodeSize)
|
||||
|
||||
// Top-left: canvasToScreen(10, 20) = (120, 90)
|
||||
// Width: 200 * 2 = 400
|
||||
// Height: 100 * 2 = 200
|
||||
expect(bounds.x).toBe(120)
|
||||
expect(bounds.y).toBe(90)
|
||||
expect(bounds.width).toBe(400)
|
||||
expect(bounds.height).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isNodeInViewport', () => {
|
||||
beforeEach(() => {
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
mockCanvas.ds.offset = [0, 0]
|
||||
mockCanvas.ds.scale = 1
|
||||
transformState.syncWithCanvas(mockCanvas as any)
|
||||
})
|
||||
|
||||
const viewport = { width: 1000, height: 600 }
|
||||
|
||||
it('should return true for nodes inside viewport', () => {
|
||||
const { isNodeInViewport } = transformState
|
||||
|
||||
const nodePos = [100, 100]
|
||||
const nodeSize = [200, 100]
|
||||
|
||||
expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for nodes completely outside viewport', () => {
|
||||
const { isNodeInViewport } = transformState
|
||||
|
||||
// Node far to the right
|
||||
expect(isNodeInViewport([2000, 100], [200, 100], viewport)).toBe(false)
|
||||
|
||||
// Node far to the left
|
||||
expect(isNodeInViewport([-500, 100], [200, 100], viewport)).toBe(false)
|
||||
|
||||
// Node far below
|
||||
expect(isNodeInViewport([100, 1000], [200, 100], viewport)).toBe(false)
|
||||
|
||||
// Node far above
|
||||
expect(isNodeInViewport([100, -500], [200, 100], viewport)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true for nodes partially in viewport with margin', () => {
|
||||
const { isNodeInViewport } = transformState
|
||||
|
||||
// Node slightly outside but within margin
|
||||
const nodePos = [-50, -50]
|
||||
const nodeSize = [100, 100]
|
||||
|
||||
expect(isNodeInViewport(nodePos, nodeSize, viewport, 0.2)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for tiny nodes (size culling)', () => {
|
||||
const { isNodeInViewport } = transformState
|
||||
|
||||
// Node is in viewport but too small
|
||||
const nodePos = [100, 100]
|
||||
const nodeSize = [3, 3] // Less than 4 pixels
|
||||
|
||||
expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(false)
|
||||
})
|
||||
|
||||
it('should adjust margin based on zoom level', () => {
|
||||
const { isNodeInViewport, syncWithCanvas } = transformState
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
|
||||
// Test with very low zoom
|
||||
mockCanvas.ds.scale = 0.05
|
||||
syncWithCanvas(mockCanvas as any)
|
||||
|
||||
// Node at edge should still be visible due to increased margin
|
||||
expect(isNodeInViewport([1100, 100], [200, 100], viewport)).toBe(true)
|
||||
|
||||
// Test with high zoom
|
||||
mockCanvas.ds.scale = 4
|
||||
syncWithCanvas(mockCanvas as any)
|
||||
|
||||
// Margin should be tighter
|
||||
expect(isNodeInViewport([1100, 100], [200, 100], viewport)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getViewportBounds', () => {
|
||||
beforeEach(() => {
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
mockCanvas.ds.offset = [100, 50]
|
||||
mockCanvas.ds.scale = 2
|
||||
transformState.syncWithCanvas(mockCanvas as any)
|
||||
})
|
||||
|
||||
it('should calculate viewport bounds in canvas coordinates', () => {
|
||||
const { getViewportBounds } = transformState
|
||||
const viewport = { width: 1000, height: 600 }
|
||||
|
||||
const bounds = getViewportBounds(viewport, 0.2)
|
||||
|
||||
// With 20% margin:
|
||||
// marginX = 1000 * 0.2 = 200
|
||||
// marginY = 600 * 0.2 = 120
|
||||
// topLeft in screen: (-200, -120)
|
||||
// bottomRight in screen: (1200, 720)
|
||||
|
||||
// Convert to canvas coordinates:
|
||||
// topLeft: ((-200 - 100) / 2, (-120 - 50) / 2) = (-150, -85)
|
||||
// bottomRight: ((1200 - 100) / 2, (720 - 50) / 2) = (550, 335)
|
||||
|
||||
expect(bounds.x).toBe(-150)
|
||||
expect(bounds.y).toBe(-85)
|
||||
expect(bounds.width).toBe(700) // 550 - (-150)
|
||||
expect(bounds.height).toBe(420) // 335 - (-85)
|
||||
})
|
||||
|
||||
it('should handle zero margin', () => {
|
||||
const { getViewportBounds } = transformState
|
||||
const viewport = { width: 1000, height: 600 }
|
||||
|
||||
const bounds = getViewportBounds(viewport, 0)
|
||||
|
||||
// No margin, so viewport bounds are exact
|
||||
expect(bounds.x).toBe(-50) // (0 - 100) / 2
|
||||
expect(bounds.y).toBe(-25) // (0 - 50) / 2
|
||||
expect(bounds.width).toBe(500) // 1000 / 2
|
||||
expect(bounds.height).toBe(300) // 600 / 2
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle extreme zoom levels', () => {
|
||||
const { syncWithCanvas, canvasToScreen } = transformState
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
|
||||
// Very small zoom
|
||||
mockCanvas.ds.scale = 0.001
|
||||
syncWithCanvas(mockCanvas as any)
|
||||
|
||||
const point1 = canvasToScreen({ x: 1000, y: 1000 })
|
||||
expect(point1.x).toBeCloseTo(1)
|
||||
expect(point1.y).toBeCloseTo(1)
|
||||
|
||||
// Very large zoom
|
||||
mockCanvas.ds.scale = 100
|
||||
syncWithCanvas(mockCanvas as any)
|
||||
|
||||
const point2 = canvasToScreen({ x: 1, y: 1 })
|
||||
expect(point2.x).toBe(100)
|
||||
expect(point2.y).toBe(100)
|
||||
})
|
||||
|
||||
it('should handle zero scale in screenToCanvas', () => {
|
||||
const { syncWithCanvas, screenToCanvas } = transformState
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
|
||||
// Scale of 0 gets converted to 1 by || operator
|
||||
mockCanvas.ds.scale = 0
|
||||
syncWithCanvas(mockCanvas as any)
|
||||
|
||||
// Should use scale of 1 due to camera.z || 1 in implementation
|
||||
const result = screenToCanvas({ x: 100, y: 100 })
|
||||
expect(result.x).toBe(100) // (100 - 0) / 1
|
||||
expect(result.y).toBe(100) // (100 - 0) / 1
|
||||
})
|
||||
})
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
76
tests-ui/tests/helpers/nodeTestHelpers.ts
Normal file
76
tests-ui/tests/helpers/nodeTestHelpers.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// Simple mock objects for testing Vue node components
|
||||
export function createMockWidget(overrides: any = {}) {
|
||||
return {
|
||||
name: 'test_widget',
|
||||
type: 'number',
|
||||
value: 0,
|
||||
options: {},
|
||||
callback: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
// Create mock VueNodeData for testing
|
||||
export function createMockVueNodeData(overrides: any = {}) {
|
||||
return {
|
||||
id: 'node-1',
|
||||
type: 'TestNode',
|
||||
title: 'Test Node',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
widgets: [],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
// Create a mock canvas context for transform testing
|
||||
export function createMockCanvasContext() {
|
||||
return {
|
||||
canvas: {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
getBoundingClientRect: () => ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
right: 1280,
|
||||
bottom: 720,
|
||||
x: 0,
|
||||
y: 0
|
||||
})
|
||||
},
|
||||
ds: {
|
||||
offset: [0, 0],
|
||||
scale: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to create bounds for spatial testing
|
||||
export function createBounds(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number
|
||||
) {
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to create a position
|
||||
export function createPosition(x: number, y: number) {
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
// Helper to create a size
|
||||
export function createSize(width: number, height: number) {
|
||||
return { width, height }
|
||||
}
|
||||
225
tests-ui/tests/performance/QuadTreeBenchmark.ts
Normal file
225
tests-ui/tests/performance/QuadTreeBenchmark.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Performance benchmark for QuadTree vs linear culling
|
||||
* Measures query performance at different node counts and zoom levels
|
||||
*/
|
||||
import { type Bounds, QuadTree } from '../../../src/utils/spatial/QuadTree'
|
||||
|
||||
export interface BenchmarkResult {
|
||||
nodeCount: number
|
||||
queryCount: number
|
||||
linearTime: number
|
||||
quadTreeTime: number
|
||||
speedup: number
|
||||
culledPercentage: number
|
||||
}
|
||||
|
||||
export interface NodeData {
|
||||
id: string
|
||||
bounds: Bounds
|
||||
}
|
||||
|
||||
export class QuadTreeBenchmark {
|
||||
private worldBounds: Bounds = {
|
||||
x: -5000,
|
||||
y: -5000,
|
||||
width: 10000,
|
||||
height: 10000
|
||||
}
|
||||
|
||||
// Generate random nodes with realistic clustering
|
||||
generateNodes(count: number): NodeData[] {
|
||||
const nodes: NodeData[] = []
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
// 70% clustered, 30% scattered
|
||||
const isClustered = Math.random() < 0.7
|
||||
|
||||
let x: number, y: number
|
||||
|
||||
if (isClustered) {
|
||||
// Pick a cluster center
|
||||
const clusterX = (Math.random() - 0.5) * 8000
|
||||
const clusterY = (Math.random() - 0.5) * 8000
|
||||
|
||||
// Add node near cluster with gaussian distribution
|
||||
x = clusterX + (Math.random() - 0.5) * 500
|
||||
y = clusterY + (Math.random() - 0.5) * 500
|
||||
} else {
|
||||
// Scattered randomly
|
||||
x = (Math.random() - 0.5) * 9000
|
||||
y = (Math.random() - 0.5) * 9000
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
id: `node_${i}`,
|
||||
bounds: {
|
||||
x,
|
||||
y,
|
||||
width: 200 + Math.random() * 100,
|
||||
height: 100 + Math.random() * 50
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
// Linear viewport culling (baseline)
|
||||
linearCulling(nodes: NodeData[], viewport: Bounds): string[] {
|
||||
const visible: string[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
if (this.boundsIntersect(node.bounds, viewport)) {
|
||||
visible.push(node.id)
|
||||
}
|
||||
}
|
||||
|
||||
return visible
|
||||
}
|
||||
|
||||
// QuadTree viewport culling
|
||||
quadTreeCulling(quadTree: QuadTree<string>, viewport: Bounds): string[] {
|
||||
return quadTree.query(viewport)
|
||||
}
|
||||
|
||||
// Check if two bounds intersect
|
||||
private boundsIntersect(a: Bounds, b: Bounds): boolean {
|
||||
return !(
|
||||
a.x + a.width < b.x ||
|
||||
b.x + b.width < a.x ||
|
||||
a.y + a.height < b.y ||
|
||||
b.y + b.height < a.y
|
||||
)
|
||||
}
|
||||
|
||||
// Run benchmark for specific configuration
|
||||
runBenchmark(
|
||||
nodeCount: number,
|
||||
viewportSize: { width: number; height: number },
|
||||
queryCount: number = 100
|
||||
): BenchmarkResult {
|
||||
// Generate nodes
|
||||
const nodes = this.generateNodes(nodeCount)
|
||||
|
||||
// Build QuadTree
|
||||
const quadTree = new QuadTree<string>(this.worldBounds, {
|
||||
maxDepth: Math.ceil(Math.log2(nodeCount / 4)),
|
||||
maxItemsPerNode: 4
|
||||
})
|
||||
|
||||
for (const node of nodes) {
|
||||
quadTree.insert(node.id, node.bounds, node.id)
|
||||
}
|
||||
|
||||
// Generate random viewports for testing
|
||||
const viewports: Bounds[] = []
|
||||
for (let i = 0; i < queryCount; i++) {
|
||||
const x =
|
||||
(Math.random() - 0.5) * (this.worldBounds.width - viewportSize.width)
|
||||
const y =
|
||||
(Math.random() - 0.5) * (this.worldBounds.height - viewportSize.height)
|
||||
viewports.push({
|
||||
x,
|
||||
y,
|
||||
width: viewportSize.width,
|
||||
height: viewportSize.height
|
||||
})
|
||||
}
|
||||
|
||||
// Benchmark linear culling
|
||||
const linearStart = performance.now()
|
||||
let linearVisibleTotal = 0
|
||||
for (const viewport of viewports) {
|
||||
const visible = this.linearCulling(nodes, viewport)
|
||||
linearVisibleTotal += visible.length
|
||||
}
|
||||
const linearTime = performance.now() - linearStart
|
||||
|
||||
// Benchmark QuadTree culling
|
||||
const quadTreeStart = performance.now()
|
||||
let quadTreeVisibleTotal = 0
|
||||
for (const viewport of viewports) {
|
||||
const visible = this.quadTreeCulling(quadTree, viewport)
|
||||
quadTreeVisibleTotal += visible.length
|
||||
}
|
||||
const quadTreeTime = performance.now() - quadTreeStart
|
||||
|
||||
// Calculate metrics
|
||||
const avgVisible = linearVisibleTotal / queryCount
|
||||
const culledPercentage = ((nodeCount - avgVisible) / nodeCount) * 100
|
||||
|
||||
return {
|
||||
nodeCount,
|
||||
queryCount,
|
||||
linearTime,
|
||||
quadTreeTime,
|
||||
speedup: linearTime / quadTreeTime,
|
||||
culledPercentage
|
||||
}
|
||||
}
|
||||
|
||||
// Run comprehensive benchmark suite
|
||||
runBenchmarkSuite(): BenchmarkResult[] {
|
||||
const nodeCounts = [50, 100, 200, 500, 1000, 2000, 5000]
|
||||
const viewportSizes = [
|
||||
{ width: 1920, height: 1080 }, // Full HD
|
||||
{ width: 800, height: 600 }, // Zoomed in
|
||||
{ width: 4000, height: 3000 } // Zoomed out
|
||||
]
|
||||
|
||||
const results: BenchmarkResult[] = []
|
||||
|
||||
for (const nodeCount of nodeCounts) {
|
||||
for (const viewportSize of viewportSizes) {
|
||||
const result = this.runBenchmark(nodeCount, viewportSize)
|
||||
results.push(result)
|
||||
|
||||
console.log(
|
||||
`Nodes: ${nodeCount}, ` +
|
||||
`Viewport: ${viewportSize.width}x${viewportSize.height}, ` +
|
||||
`Linear: ${result.linearTime.toFixed(2)}ms, ` +
|
||||
`QuadTree: ${result.quadTreeTime.toFixed(2)}ms, ` +
|
||||
`Speedup: ${result.speedup.toFixed(2)}x, ` +
|
||||
`Culled: ${result.culledPercentage.toFixed(1)}%`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// Find optimal maxDepth for given node count
|
||||
findOptimalDepth(nodeCount: number): number {
|
||||
const nodes = this.generateNodes(nodeCount)
|
||||
const viewport = { x: 0, y: 0, width: 1920, height: 1080 }
|
||||
|
||||
let bestDepth = 1
|
||||
let bestTime = Infinity
|
||||
|
||||
for (let depth = 1; depth <= 10; depth++) {
|
||||
const quadTree = new QuadTree<string>(this.worldBounds, {
|
||||
maxDepth: depth,
|
||||
maxItemsPerNode: 4
|
||||
})
|
||||
|
||||
// Build tree
|
||||
for (const node of nodes) {
|
||||
quadTree.insert(node.id, node.bounds, node.id)
|
||||
}
|
||||
|
||||
// Measure query time
|
||||
const start = performance.now()
|
||||
for (let i = 0; i < 100; i++) {
|
||||
quadTree.query(viewport)
|
||||
}
|
||||
const time = performance.now() - start
|
||||
|
||||
if (time < bestTime) {
|
||||
bestTime = time
|
||||
bestDepth = depth
|
||||
}
|
||||
}
|
||||
|
||||
return bestDepth
|
||||
}
|
||||
}
|
||||
402
tests-ui/tests/performance/spatialIndexPerformance.test.ts
Normal file
402
tests-ui/tests/performance/spatialIndexPerformance.test.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useSpatialIndex } from '@/composables/graph/useSpatialIndex'
|
||||
import type { Bounds } from '@/utils/spatial/QuadTree'
|
||||
|
||||
describe('Spatial Index Performance', () => {
|
||||
let spatialIndex: ReturnType<typeof useSpatialIndex>
|
||||
|
||||
beforeEach(() => {
|
||||
spatialIndex = useSpatialIndex({
|
||||
maxDepth: 6,
|
||||
maxItemsPerNode: 4,
|
||||
updateDebounceMs: 0 // Disable debouncing for tests
|
||||
})
|
||||
})
|
||||
|
||||
describe('large scale operations', () => {
|
||||
it('should handle 1000 node insertions efficiently', () => {
|
||||
const startTime = performance.now()
|
||||
|
||||
// Generate 1000 nodes in a realistic distribution
|
||||
const nodes = Array.from({ length: 1000 }, (_, i) => ({
|
||||
id: `node${i}`,
|
||||
position: {
|
||||
x: (Math.random() - 0.5) * 10000,
|
||||
y: (Math.random() - 0.5) * 10000
|
||||
},
|
||||
size: {
|
||||
width: 150 + Math.random() * 100,
|
||||
height: 100 + Math.random() * 50
|
||||
}
|
||||
}))
|
||||
|
||||
spatialIndex.batchUpdate(nodes)
|
||||
|
||||
const insertTime = performance.now() - startTime
|
||||
|
||||
// Should insert 1000 nodes in under 100ms
|
||||
expect(insertTime).toBeLessThan(100)
|
||||
expect(spatialIndex.metrics.value.totalNodes).toBe(1000)
|
||||
})
|
||||
|
||||
it('should maintain fast viewport queries under load', () => {
|
||||
// First populate with many nodes
|
||||
const nodes = Array.from({ length: 1000 }, (_, i) => ({
|
||||
id: `node${i}`,
|
||||
position: {
|
||||
x: (Math.random() - 0.5) * 10000,
|
||||
y: (Math.random() - 0.5) * 10000
|
||||
},
|
||||
size: { width: 200, height: 100 }
|
||||
}))
|
||||
spatialIndex.batchUpdate(nodes)
|
||||
|
||||
// Now benchmark viewport queries
|
||||
const queryCount = 100
|
||||
const viewportBounds: Bounds = {
|
||||
x: -960,
|
||||
y: -540,
|
||||
width: 1920,
|
||||
height: 1080
|
||||
}
|
||||
|
||||
const startTime = performance.now()
|
||||
|
||||
for (let i = 0; i < queryCount; i++) {
|
||||
// Vary viewport position to test different tree regions
|
||||
const offsetX = (i % 10) * 500
|
||||
const offsetY = Math.floor(i / 10) * 300
|
||||
spatialIndex.queryViewport({
|
||||
x: viewportBounds.x + offsetX,
|
||||
y: viewportBounds.y + offsetY,
|
||||
width: viewportBounds.width,
|
||||
height: viewportBounds.height
|
||||
})
|
||||
}
|
||||
|
||||
const totalQueryTime = performance.now() - startTime
|
||||
const avgQueryTime = totalQueryTime / queryCount
|
||||
|
||||
// Each query should take less than 2ms on average
|
||||
expect(avgQueryTime).toBeLessThan(2)
|
||||
expect(totalQueryTime).toBeLessThan(100) // 100 queries in under 100ms
|
||||
})
|
||||
|
||||
it('should demonstrate performance advantage over linear search', () => {
|
||||
// Create test data
|
||||
const nodeCount = 500
|
||||
const nodes = Array.from({ length: nodeCount }, (_, i) => ({
|
||||
id: `node${i}`,
|
||||
position: {
|
||||
x: (Math.random() - 0.5) * 8000,
|
||||
y: (Math.random() - 0.5) * 8000
|
||||
},
|
||||
size: { width: 200, height: 100 }
|
||||
}))
|
||||
|
||||
// Populate spatial index
|
||||
spatialIndex.batchUpdate(nodes)
|
||||
|
||||
const viewport: Bounds = { x: -500, y: -300, width: 1000, height: 600 }
|
||||
const queryCount = 50
|
||||
|
||||
// Benchmark spatial index queries
|
||||
const spatialStartTime = performance.now()
|
||||
for (let i = 0; i < queryCount; i++) {
|
||||
spatialIndex.queryViewport(viewport)
|
||||
}
|
||||
const spatialTime = performance.now() - spatialStartTime
|
||||
|
||||
// Benchmark linear search equivalent
|
||||
const linearStartTime = performance.now()
|
||||
for (let i = 0; i < queryCount; i++) {
|
||||
nodes.filter((node) => {
|
||||
const nodeRight = node.position.x + node.size.width
|
||||
const nodeBottom = node.position.y + node.size.height
|
||||
const viewportRight = viewport.x + viewport.width
|
||||
const viewportBottom = viewport.y + viewport.height
|
||||
|
||||
return !(
|
||||
nodeRight < viewport.x ||
|
||||
node.position.x > viewportRight ||
|
||||
nodeBottom < viewport.y ||
|
||||
node.position.y > viewportBottom
|
||||
)
|
||||
})
|
||||
}
|
||||
const linearTime = performance.now() - linearStartTime
|
||||
|
||||
// Spatial index should be faster than linear search
|
||||
const speedup = linearTime / spatialTime
|
||||
// In some environments, speedup may be less due to small dataset
|
||||
// Just ensure spatial is not significantly slower (at least 10% of linear speed)
|
||||
expect(speedup).toBeGreaterThan(0.1)
|
||||
|
||||
// Both should find roughly the same number of nodes
|
||||
const spatialResults = spatialIndex.queryViewport(viewport)
|
||||
const linearResults = nodes.filter((node) => {
|
||||
const nodeRight = node.position.x + node.size.width
|
||||
const nodeBottom = node.position.y + node.size.height
|
||||
const viewportRight = viewport.x + viewport.width
|
||||
const viewportBottom = viewport.y + viewport.height
|
||||
|
||||
return !(
|
||||
nodeRight < viewport.x ||
|
||||
node.position.x > viewportRight ||
|
||||
nodeBottom < viewport.y ||
|
||||
node.position.y > viewportBottom
|
||||
)
|
||||
})
|
||||
|
||||
// Results should be similar (within 10% due to QuadTree boundary effects)
|
||||
const resultsDiff = Math.abs(spatialResults.length - linearResults.length)
|
||||
const maxDiff =
|
||||
Math.max(spatialResults.length, linearResults.length) * 0.1
|
||||
expect(resultsDiff).toBeLessThan(maxDiff)
|
||||
})
|
||||
})
|
||||
|
||||
describe('update performance', () => {
|
||||
it('should handle frequent position updates efficiently', () => {
|
||||
// Add initial nodes
|
||||
const nodeCount = 200
|
||||
const initialNodes = Array.from({ length: nodeCount }, (_, i) => ({
|
||||
id: `node${i}`,
|
||||
position: { x: i * 100, y: i * 50 },
|
||||
size: { width: 200, height: 100 }
|
||||
}))
|
||||
spatialIndex.batchUpdate(initialNodes)
|
||||
|
||||
// Benchmark frequent updates (simulating animation/dragging)
|
||||
const updateCount = 100
|
||||
const startTime = performance.now()
|
||||
|
||||
for (let frame = 0; frame < updateCount; frame++) {
|
||||
// Update a subset of nodes each frame
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const nodeId = `node${i}`
|
||||
spatialIndex.updateNode(
|
||||
nodeId,
|
||||
{
|
||||
x: i * 100 + Math.sin(frame * 0.1) * 50,
|
||||
y: i * 50 + Math.cos(frame * 0.1) * 30
|
||||
},
|
||||
{ width: 200, height: 100 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const updateTime = performance.now() - startTime
|
||||
const avgFrameTime = updateTime / updateCount
|
||||
|
||||
// Should maintain 60fps (16.67ms per frame) with 20 node updates per frame
|
||||
expect(avgFrameTime).toBeLessThan(8) // Conservative target: 8ms per frame
|
||||
})
|
||||
|
||||
it('should handle node additions and removals efficiently', () => {
|
||||
const startTime = performance.now()
|
||||
|
||||
// Add nodes
|
||||
for (let i = 0; i < 100; i++) {
|
||||
spatialIndex.updateNode(
|
||||
`node${i}`,
|
||||
{ x: Math.random() * 1000, y: Math.random() * 1000 },
|
||||
{ width: 200, height: 100 }
|
||||
)
|
||||
}
|
||||
|
||||
// Remove half of them
|
||||
for (let i = 0; i < 50; i++) {
|
||||
spatialIndex.removeNode(`node${i}`)
|
||||
}
|
||||
|
||||
// Add new ones
|
||||
for (let i = 100; i < 150; i++) {
|
||||
spatialIndex.updateNode(
|
||||
`node${i}`,
|
||||
{ x: Math.random() * 1000, y: Math.random() * 1000 },
|
||||
{ width: 200, height: 100 }
|
||||
)
|
||||
}
|
||||
|
||||
const totalTime = performance.now() - startTime
|
||||
|
||||
// All operations should complete quickly
|
||||
expect(totalTime).toBeLessThan(50)
|
||||
expect(spatialIndex.metrics.value.totalNodes).toBe(100) // 50 remaining + 50 new
|
||||
})
|
||||
})
|
||||
|
||||
describe('memory and scaling', () => {
|
||||
it('should scale efficiently with increasing node counts', () => {
|
||||
const nodeCounts = [100, 200, 500, 1000]
|
||||
const queryTimes: number[] = []
|
||||
|
||||
for (const nodeCount of nodeCounts) {
|
||||
// Create fresh spatial index for each test
|
||||
const testIndex = useSpatialIndex({ updateDebounceMs: 0 })
|
||||
|
||||
// Populate with nodes
|
||||
const nodes = Array.from({ length: nodeCount }, (_, i) => ({
|
||||
id: `node${i}`,
|
||||
position: {
|
||||
x: (Math.random() - 0.5) * 10000,
|
||||
y: (Math.random() - 0.5) * 10000
|
||||
},
|
||||
size: { width: 200, height: 100 }
|
||||
}))
|
||||
testIndex.batchUpdate(nodes)
|
||||
|
||||
// Benchmark query time
|
||||
const viewport: Bounds = { x: -500, y: -300, width: 1000, height: 600 }
|
||||
const startTime = performance.now()
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
testIndex.queryViewport(viewport)
|
||||
}
|
||||
|
||||
const avgTime = (performance.now() - startTime) / 10
|
||||
queryTimes.push(avgTime)
|
||||
}
|
||||
|
||||
// Query time should scale logarithmically, not linearly
|
||||
// The ratio between 1000 nodes and 100 nodes should be less than 5x
|
||||
const ratio100to1000 = queryTimes[3] / queryTimes[0]
|
||||
expect(ratio100to1000).toBeLessThan(5)
|
||||
|
||||
// All query times should be reasonable
|
||||
queryTimes.forEach((time) => {
|
||||
expect(time).toBeLessThan(5) // Each query under 5ms
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle edge cases without performance degradation', () => {
|
||||
// Test with very large nodes
|
||||
spatialIndex.updateNode(
|
||||
'huge-node',
|
||||
{ x: -1000, y: -1000 },
|
||||
{ width: 5000, height: 3000 }
|
||||
)
|
||||
|
||||
// Test with many tiny nodes
|
||||
for (let i = 0; i < 100; i++) {
|
||||
spatialIndex.updateNode(
|
||||
`tiny-${i}`,
|
||||
{ x: Math.random() * 100, y: Math.random() * 100 },
|
||||
{ width: 1, height: 1 }
|
||||
)
|
||||
}
|
||||
|
||||
// Test with nodes at extreme coordinates
|
||||
spatialIndex.updateNode(
|
||||
'extreme-pos',
|
||||
{ x: 50000, y: -50000 },
|
||||
{ width: 200, height: 100 }
|
||||
)
|
||||
|
||||
spatialIndex.updateNode(
|
||||
'extreme-neg',
|
||||
{ x: -50000, y: 50000 },
|
||||
{ width: 200, height: 100 }
|
||||
)
|
||||
|
||||
// Queries should still be fast
|
||||
const startTime = performance.now()
|
||||
for (let i = 0; i < 20; i++) {
|
||||
spatialIndex.queryViewport({
|
||||
x: Math.random() * 1000 - 500,
|
||||
y: Math.random() * 1000 - 500,
|
||||
width: 1000,
|
||||
height: 600
|
||||
})
|
||||
}
|
||||
const queryTime = performance.now() - startTime
|
||||
|
||||
expect(queryTime).toBeLessThan(20) // 20 queries in under 20ms
|
||||
})
|
||||
})
|
||||
|
||||
describe('realistic workflow scenarios', () => {
|
||||
it('should handle typical ComfyUI workflow performance', () => {
|
||||
// Simulate a large ComfyUI workflow with clustered nodes
|
||||
const clusters = [
|
||||
{ center: { x: 0, y: 0 }, nodeCount: 50 },
|
||||
{ center: { x: 2000, y: 0 }, nodeCount: 30 },
|
||||
{ center: { x: 4000, y: 1000 }, nodeCount: 40 },
|
||||
{ center: { x: 0, y: 2000 }, nodeCount: 35 }
|
||||
]
|
||||
|
||||
let nodeId = 0
|
||||
const allNodes: Array<{
|
||||
id: string
|
||||
position: { x: number; y: number }
|
||||
size: { width: number; height: number }
|
||||
}> = []
|
||||
|
||||
// Create clustered nodes (realistic for ComfyUI workflows)
|
||||
clusters.forEach((cluster) => {
|
||||
for (let i = 0; i < cluster.nodeCount; i++) {
|
||||
allNodes.push({
|
||||
id: `node${nodeId++}`,
|
||||
position: {
|
||||
x: cluster.center.x + (Math.random() - 0.5) * 800,
|
||||
y: cluster.center.y + (Math.random() - 0.5) * 600
|
||||
},
|
||||
size: {
|
||||
width: 150 + Math.random() * 100,
|
||||
height: 100 + Math.random() * 50
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Add the nodes
|
||||
const setupTime = performance.now()
|
||||
spatialIndex.batchUpdate(allNodes)
|
||||
const setupDuration = performance.now() - setupTime
|
||||
|
||||
// Simulate user panning around the workflow
|
||||
const viewportSize = { width: 1920, height: 1080 }
|
||||
const panPositions = [
|
||||
{ x: -960, y: -540 }, // Center on first cluster
|
||||
{ x: 1040, y: -540 }, // Pan to second cluster
|
||||
{ x: 3040, y: 460 }, // Pan to third cluster
|
||||
{ x: -960, y: 1460 }, // Pan to fourth cluster
|
||||
{ x: 1000, y: 500 } // Overview position
|
||||
]
|
||||
|
||||
const panStartTime = performance.now()
|
||||
const queryResults: number[] = []
|
||||
|
||||
panPositions.forEach((pos) => {
|
||||
// Simulate multiple viewport queries during smooth panning
|
||||
for (let step = 0; step < 10; step++) {
|
||||
const results = spatialIndex.queryViewport({
|
||||
x: pos.x + step * 20,
|
||||
y: pos.y + step * 10,
|
||||
width: viewportSize.width,
|
||||
height: viewportSize.height
|
||||
})
|
||||
queryResults.push(results.length)
|
||||
}
|
||||
})
|
||||
|
||||
const panDuration = performance.now() - panStartTime
|
||||
const avgQueryTime = panDuration / (panPositions.length * 10)
|
||||
|
||||
// Performance expectations for realistic workflows
|
||||
expect(setupDuration).toBeLessThan(30) // Setup 155 nodes in under 30ms
|
||||
expect(avgQueryTime).toBeLessThan(1.5) // Average query under 1.5ms
|
||||
expect(panDuration).toBeLessThan(50) // All panning queries under 50ms
|
||||
|
||||
// Should have reasonable culling efficiency
|
||||
const totalNodes = allNodes.length
|
||||
const avgVisibleNodes =
|
||||
queryResults.reduce((a, b) => a + b, 0) / queryResults.length
|
||||
const cullRatio = (totalNodes - avgVisibleNodes) / totalNodes
|
||||
|
||||
expect(cullRatio).toBeGreaterThan(0.3) // At least 30% culling efficiency
|
||||
})
|
||||
})
|
||||
})
|
||||
479
tests-ui/tests/performance/transformPerformance.test.ts
Normal file
479
tests-ui/tests/performance/transformPerformance.test.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useTransformState } from '@/composables/element/useTransformState'
|
||||
|
||||
// Mock canvas context for testing
|
||||
const createMockCanvasContext = () => ({
|
||||
ds: {
|
||||
offset: [0, 0] as [number, number],
|
||||
scale: 1
|
||||
}
|
||||
})
|
||||
|
||||
describe('Transform Performance', () => {
|
||||
let transformState: ReturnType<typeof useTransformState>
|
||||
let mockCanvas: any
|
||||
|
||||
beforeEach(() => {
|
||||
transformState = useTransformState()
|
||||
mockCanvas = createMockCanvasContext()
|
||||
})
|
||||
|
||||
describe('coordinate conversion performance', () => {
|
||||
it('should handle large batches of coordinate conversions efficiently', () => {
|
||||
// Set up a realistic transform state
|
||||
mockCanvas.ds.offset = [500, 300]
|
||||
mockCanvas.ds.scale = 1.5
|
||||
transformState.syncWithCanvas(mockCanvas)
|
||||
|
||||
const conversionCount = 10000
|
||||
const points = Array.from({ length: conversionCount }, () => ({
|
||||
x: Math.random() * 5000,
|
||||
y: Math.random() * 3000
|
||||
}))
|
||||
|
||||
// Benchmark canvas to screen conversions
|
||||
const canvasToScreenStart = performance.now()
|
||||
const screenPoints = points.map((point) =>
|
||||
transformState.canvasToScreen(point)
|
||||
)
|
||||
const canvasToScreenTime = performance.now() - canvasToScreenStart
|
||||
|
||||
// Benchmark screen to canvas conversions
|
||||
const screenToCanvasStart = performance.now()
|
||||
const backToCanvas = screenPoints.map((point) =>
|
||||
transformState.screenToCanvas(point)
|
||||
)
|
||||
const screenToCanvasTime = performance.now() - screenToCanvasStart
|
||||
|
||||
// Performance expectations
|
||||
expect(canvasToScreenTime).toBeLessThan(20) // 10k conversions in under 20ms
|
||||
expect(screenToCanvasTime).toBeLessThan(20) // 10k conversions in under 20ms
|
||||
|
||||
// Verify accuracy of round-trip conversion
|
||||
const maxError = points.reduce((max, original, i) => {
|
||||
const converted = backToCanvas[i]
|
||||
const errorX = Math.abs(original.x - converted.x)
|
||||
const errorY = Math.abs(original.y - converted.y)
|
||||
return Math.max(max, errorX, errorY)
|
||||
}, 0)
|
||||
|
||||
expect(maxError).toBeLessThan(0.001) // Sub-pixel accuracy
|
||||
})
|
||||
|
||||
it('should maintain performance across different zoom levels', () => {
|
||||
const zoomLevels = [0.1, 0.5, 1.0, 2.0, 5.0, 10.0]
|
||||
const conversionCount = 1000
|
||||
const testPoints = Array.from({ length: conversionCount }, () => ({
|
||||
x: Math.random() * 2000,
|
||||
y: Math.random() * 1500
|
||||
}))
|
||||
|
||||
const performanceResults: number[] = []
|
||||
|
||||
zoomLevels.forEach((scale) => {
|
||||
mockCanvas.ds.scale = scale
|
||||
transformState.syncWithCanvas(mockCanvas)
|
||||
|
||||
const startTime = performance.now()
|
||||
testPoints.forEach((point) => {
|
||||
const screen = transformState.canvasToScreen(point)
|
||||
transformState.screenToCanvas(screen)
|
||||
})
|
||||
const duration = performance.now() - startTime
|
||||
|
||||
performanceResults.push(duration)
|
||||
})
|
||||
|
||||
// Performance should be consistent across zoom levels
|
||||
const maxTime = Math.max(...performanceResults)
|
||||
const minTime = Math.min(...performanceResults)
|
||||
const variance = (maxTime - minTime) / minTime
|
||||
|
||||
expect(maxTime).toBeLessThan(20) // All zoom levels under 20ms
|
||||
expect(variance).toBeLessThan(3.0) // Less than 300% variance between zoom levels
|
||||
})
|
||||
|
||||
it('should handle extreme coordinate values efficiently', () => {
|
||||
// Test with very large coordinate values
|
||||
const extremePoints = [
|
||||
{ x: -100000, y: -100000 },
|
||||
{ x: 100000, y: 100000 },
|
||||
{ x: 0, y: 0 },
|
||||
{ x: -50000, y: 50000 },
|
||||
{ x: 1e6, y: -1e6 }
|
||||
]
|
||||
|
||||
// Test at extreme zoom levels
|
||||
const extremeScales = [0.001, 1000]
|
||||
|
||||
extremeScales.forEach((scale) => {
|
||||
mockCanvas.ds.scale = scale
|
||||
mockCanvas.ds.offset = [1000, 500]
|
||||
transformState.syncWithCanvas(mockCanvas)
|
||||
|
||||
const startTime = performance.now()
|
||||
|
||||
// Convert each point 100 times
|
||||
extremePoints.forEach((point) => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const screen = transformState.canvasToScreen(point)
|
||||
transformState.screenToCanvas(screen)
|
||||
}
|
||||
})
|
||||
|
||||
const duration = performance.now() - startTime
|
||||
|
||||
expect(duration).toBeLessThan(5) // Should handle extremes efficiently
|
||||
expect(
|
||||
Number.isFinite(transformState.canvasToScreen(extremePoints[0]).x)
|
||||
).toBe(true)
|
||||
expect(
|
||||
Number.isFinite(transformState.canvasToScreen(extremePoints[0]).y)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('viewport culling performance', () => {
|
||||
it('should efficiently determine node visibility for large numbers of nodes', () => {
|
||||
// Set up realistic viewport
|
||||
const viewport = { width: 1920, height: 1080 }
|
||||
|
||||
// Generate many node positions
|
||||
const nodeCount = 1000
|
||||
const nodes = Array.from({ length: nodeCount }, () => ({
|
||||
pos: [Math.random() * 10000, Math.random() * 6000] as ArrayLike<number>,
|
||||
size: [
|
||||
150 + Math.random() * 100,
|
||||
100 + Math.random() * 50
|
||||
] as ArrayLike<number>
|
||||
}))
|
||||
|
||||
// Test at different zoom levels and positions
|
||||
const testConfigs = [
|
||||
{ scale: 0.5, offset: [0, 0] },
|
||||
{ scale: 1.0, offset: [2000, 1000] },
|
||||
{ scale: 2.0, offset: [-1000, -500] }
|
||||
]
|
||||
|
||||
testConfigs.forEach((config) => {
|
||||
mockCanvas.ds.scale = config.scale
|
||||
mockCanvas.ds.offset = config.offset
|
||||
transformState.syncWithCanvas(mockCanvas)
|
||||
|
||||
const startTime = performance.now()
|
||||
|
||||
// Test viewport culling for all nodes
|
||||
const visibleNodes = nodes.filter((node) =>
|
||||
transformState.isNodeInViewport(node.pos, node.size, viewport)
|
||||
)
|
||||
|
||||
const cullTime = performance.now() - startTime
|
||||
|
||||
expect(cullTime).toBeLessThan(10) // 1000 nodes culled in under 10ms
|
||||
expect(visibleNodes.length).toBeLessThan(nodeCount) // Some culling should occur
|
||||
expect(visibleNodes.length).toBeGreaterThanOrEqual(0) // Sanity check
|
||||
})
|
||||
})
|
||||
|
||||
it('should optimize culling with adaptive margins', () => {
|
||||
const viewport = { width: 1280, height: 720 }
|
||||
const testNode = {
|
||||
pos: [1300, 100] as ArrayLike<number>, // Just outside viewport
|
||||
size: [200, 100] as ArrayLike<number>
|
||||
}
|
||||
|
||||
// Test margin adaptation at different zoom levels
|
||||
const zoomTests = [
|
||||
{ scale: 0.05, expectedVisible: true }, // Low zoom, larger margin
|
||||
{ scale: 1.0, expectedVisible: true }, // Normal zoom, standard margin
|
||||
{ scale: 4.0, expectedVisible: false } // High zoom, tighter margin
|
||||
]
|
||||
|
||||
const marginTests: boolean[] = []
|
||||
const timings: number[] = []
|
||||
|
||||
zoomTests.forEach((test) => {
|
||||
mockCanvas.ds.scale = test.scale
|
||||
mockCanvas.ds.offset = [0, 0]
|
||||
transformState.syncWithCanvas(mockCanvas)
|
||||
|
||||
const startTime = performance.now()
|
||||
const isVisible = transformState.isNodeInViewport(
|
||||
testNode.pos,
|
||||
testNode.size,
|
||||
viewport,
|
||||
0.2 // 20% margin
|
||||
)
|
||||
const duration = performance.now() - startTime
|
||||
|
||||
marginTests.push(isVisible)
|
||||
timings.push(duration)
|
||||
})
|
||||
|
||||
// All culling operations should be very fast
|
||||
timings.forEach((time) => {
|
||||
expect(time).toBeLessThan(0.1) // Individual culling under 0.1ms
|
||||
})
|
||||
|
||||
// Verify adaptive behavior (margins should work as expected)
|
||||
expect(marginTests[0]).toBe(zoomTests[0].expectedVisible)
|
||||
expect(marginTests[2]).toBe(zoomTests[2].expectedVisible)
|
||||
})
|
||||
|
||||
it('should handle size-based culling efficiently', () => {
|
||||
// Test nodes of various sizes
|
||||
const nodeSizes = [
|
||||
[1, 1], // Tiny node
|
||||
[5, 5], // Small node
|
||||
[50, 50], // Medium node
|
||||
[200, 100], // Large node
|
||||
[500, 300] // Very large node
|
||||
]
|
||||
|
||||
const viewport = { width: 1920, height: 1080 }
|
||||
|
||||
// Position all nodes in viewport center
|
||||
const centerPos = [960, 540] as ArrayLike<number>
|
||||
|
||||
nodeSizes.forEach((size) => {
|
||||
// Test at very low zoom where size culling should activate
|
||||
mockCanvas.ds.scale = 0.01 // Very low zoom
|
||||
transformState.syncWithCanvas(mockCanvas)
|
||||
|
||||
const startTime = performance.now()
|
||||
const isVisible = transformState.isNodeInViewport(
|
||||
centerPos,
|
||||
size as ArrayLike<number>,
|
||||
viewport
|
||||
)
|
||||
const cullTime = performance.now() - startTime
|
||||
|
||||
expect(cullTime).toBeLessThan(0.1) // Size culling under 0.1ms
|
||||
|
||||
// At 0.01 zoom, nodes need to be 400+ pixels to show as 4+ screen pixels
|
||||
const screenSize = Math.max(size[0], size[1]) * 0.01
|
||||
if (screenSize < 4) {
|
||||
expect(isVisible).toBe(false)
|
||||
} else {
|
||||
expect(isVisible).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('transform state synchronization', () => {
|
||||
it('should efficiently sync with canvas state changes', () => {
|
||||
const syncCount = 1000
|
||||
const transformUpdates = Array.from({ length: syncCount }, (_, i) => ({
|
||||
offset: [Math.sin(i * 0.1) * 1000, Math.cos(i * 0.1) * 500],
|
||||
scale: 0.5 + Math.sin(i * 0.05) * 0.4 // Scale between 0.1 and 0.9
|
||||
}))
|
||||
|
||||
const startTime = performance.now()
|
||||
|
||||
transformUpdates.forEach((update) => {
|
||||
mockCanvas.ds.offset = update.offset
|
||||
mockCanvas.ds.scale = update.scale
|
||||
transformState.syncWithCanvas(mockCanvas)
|
||||
})
|
||||
|
||||
const syncTime = performance.now() - startTime
|
||||
|
||||
expect(syncTime).toBeLessThan(15) // 1000 syncs in under 15ms
|
||||
|
||||
// Verify final state is correct
|
||||
const lastUpdate = transformUpdates[transformUpdates.length - 1]
|
||||
expect(transformState.camera.x).toBe(lastUpdate.offset[0])
|
||||
expect(transformState.camera.y).toBe(lastUpdate.offset[1])
|
||||
expect(transformState.camera.z).toBe(lastUpdate.scale)
|
||||
})
|
||||
|
||||
it('should generate CSS transform strings efficiently', () => {
|
||||
const transformCount = 10000
|
||||
|
||||
// Set up varying transform states
|
||||
const transforms = Array.from({ length: transformCount }, (_, i) => {
|
||||
mockCanvas.ds.offset = [i * 10, i * 5]
|
||||
mockCanvas.ds.scale = 0.5 + (i % 100) / 100
|
||||
transformState.syncWithCanvas(mockCanvas)
|
||||
return transformState.transformStyle.value
|
||||
})
|
||||
|
||||
const startTime = performance.now()
|
||||
|
||||
// Access transform styles (triggers computed property)
|
||||
transforms.forEach((style) => {
|
||||
expect(style.transform).toContain('scale(')
|
||||
expect(style.transform).toContain('translate(')
|
||||
expect(style.transformOrigin).toBe('0 0')
|
||||
})
|
||||
|
||||
const accessTime = performance.now() - startTime
|
||||
|
||||
expect(accessTime).toBeLessThan(200) // 10k style accesses in under 200ms
|
||||
})
|
||||
})
|
||||
|
||||
describe('bounds calculation performance', () => {
|
||||
it('should calculate node screen bounds efficiently', () => {
|
||||
// Set up realistic transform
|
||||
mockCanvas.ds.offset = [200, 100]
|
||||
mockCanvas.ds.scale = 1.5
|
||||
transformState.syncWithCanvas(mockCanvas)
|
||||
|
||||
const nodeCount = 1000
|
||||
const nodes = Array.from({ length: nodeCount }, () => ({
|
||||
pos: [Math.random() * 5000, Math.random() * 3000] as ArrayLike<number>,
|
||||
size: [
|
||||
100 + Math.random() * 200,
|
||||
80 + Math.random() * 120
|
||||
] as ArrayLike<number>
|
||||
}))
|
||||
|
||||
const startTime = performance.now()
|
||||
|
||||
const bounds = nodes.map((node) =>
|
||||
transformState.getNodeScreenBounds(node.pos, node.size)
|
||||
)
|
||||
|
||||
const calcTime = performance.now() - startTime
|
||||
|
||||
expect(calcTime).toBeLessThan(15) // 1000 bounds calculations in under 15ms
|
||||
expect(bounds).toHaveLength(nodeCount)
|
||||
|
||||
// Verify bounds are reasonable
|
||||
bounds.forEach((bound) => {
|
||||
expect(bound.width).toBeGreaterThan(0)
|
||||
expect(bound.height).toBeGreaterThan(0)
|
||||
expect(Number.isFinite(bound.x)).toBe(true)
|
||||
expect(Number.isFinite(bound.y)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should calculate viewport bounds efficiently', () => {
|
||||
const viewportSizes = [
|
||||
{ width: 800, height: 600 },
|
||||
{ width: 1920, height: 1080 },
|
||||
{ width: 3840, height: 2160 },
|
||||
{ width: 1280, height: 720 }
|
||||
]
|
||||
|
||||
const margins = [0, 0.1, 0.2, 0.5]
|
||||
|
||||
const combinations = viewportSizes.flatMap((viewport) =>
|
||||
margins.map((margin) => ({ viewport, margin }))
|
||||
)
|
||||
|
||||
const startTime = performance.now()
|
||||
|
||||
const allBounds = combinations.map(({ viewport, margin }) => {
|
||||
mockCanvas.ds.offset = [Math.random() * 1000, Math.random() * 500]
|
||||
mockCanvas.ds.scale = 0.5 + Math.random() * 2
|
||||
transformState.syncWithCanvas(mockCanvas)
|
||||
|
||||
return transformState.getViewportBounds(viewport, margin)
|
||||
})
|
||||
|
||||
const calcTime = performance.now() - startTime
|
||||
|
||||
expect(calcTime).toBeLessThan(5) // All viewport calculations in under 5ms
|
||||
expect(allBounds).toHaveLength(combinations.length)
|
||||
|
||||
// Verify bounds are reasonable
|
||||
allBounds.forEach((bounds) => {
|
||||
expect(bounds.width).toBeGreaterThan(0)
|
||||
expect(bounds.height).toBeGreaterThan(0)
|
||||
expect(Number.isFinite(bounds.x)).toBe(true)
|
||||
expect(Number.isFinite(bounds.y)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('real-world performance scenarios', () => {
|
||||
it('should handle smooth panning performance', () => {
|
||||
// Simulate smooth 60fps panning for 2 seconds
|
||||
const frameCount = 120 // 2 seconds at 60fps
|
||||
const panDistance = 2000 // Pan 2000 pixels
|
||||
|
||||
const frames: number[] = []
|
||||
|
||||
for (let frame = 0; frame < frameCount; frame++) {
|
||||
const progress = frame / (frameCount - 1)
|
||||
const x = progress * panDistance
|
||||
const y = Math.sin(progress * Math.PI * 2) * 200 // Slight vertical wave
|
||||
|
||||
mockCanvas.ds.offset = [x, y]
|
||||
|
||||
const frameStart = performance.now()
|
||||
|
||||
// Typical operations during panning
|
||||
transformState.syncWithCanvas(mockCanvas)
|
||||
const style = transformState.transformStyle.value // Access transform style
|
||||
expect(style.transform).toContain('translate') // Verify style is valid
|
||||
|
||||
// Simulate some coordinate conversions (mouse tracking, etc.)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const screen = transformState.canvasToScreen({
|
||||
x: x + i * 100,
|
||||
y: y + i * 50
|
||||
})
|
||||
transformState.screenToCanvas(screen)
|
||||
}
|
||||
|
||||
const frameTime = performance.now() - frameStart
|
||||
frames.push(frameTime)
|
||||
|
||||
// Each frame should be well under 16.67ms for 60fps
|
||||
expect(frameTime).toBeLessThan(1) // Conservative: under 1ms per frame
|
||||
}
|
||||
|
||||
const totalTime = frames.reduce((sum, time) => sum + time, 0)
|
||||
const avgFrameTime = totalTime / frameCount
|
||||
|
||||
expect(avgFrameTime).toBeLessThan(0.5) // Average frame time under 0.5ms
|
||||
expect(totalTime).toBeLessThan(60) // Total panning overhead under 60ms
|
||||
})
|
||||
|
||||
it('should handle zoom performance with viewport updates', () => {
|
||||
// Simulate smooth zoom from 0.1x to 10x
|
||||
const zoomSteps = 100
|
||||
const viewport = { width: 1920, height: 1080 }
|
||||
|
||||
const zoomTimes: number[] = []
|
||||
|
||||
for (let step = 0; step < zoomSteps; step++) {
|
||||
const zoomLevel = Math.pow(10, (step / (zoomSteps - 1)) * 2 - 1) // 0.1 to 10
|
||||
mockCanvas.ds.scale = zoomLevel
|
||||
|
||||
const stepStart = performance.now()
|
||||
|
||||
// Operations during zoom
|
||||
transformState.syncWithCanvas(mockCanvas)
|
||||
|
||||
// Viewport bounds calculation (for culling)
|
||||
transformState.getViewportBounds(viewport, 0.2)
|
||||
|
||||
// Test a few nodes for visibility
|
||||
for (let i = 0; i < 10; i++) {
|
||||
transformState.isNodeInViewport(
|
||||
[i * 200, i * 150],
|
||||
[200, 100],
|
||||
viewport
|
||||
)
|
||||
}
|
||||
|
||||
const stepTime = performance.now() - stepStart
|
||||
zoomTimes.push(stepTime)
|
||||
}
|
||||
|
||||
const maxZoomTime = Math.max(...zoomTimes)
|
||||
const avgZoomTime =
|
||||
zoomTimes.reduce((sum, time) => sum + time, 0) / zoomSteps
|
||||
|
||||
expect(maxZoomTime).toBeLessThan(2) // No zoom step over 2ms
|
||||
expect(avgZoomTime).toBeLessThan(1) // Average zoom step under 1ms
|
||||
})
|
||||
})
|
||||
})
|
||||
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