diff --git a/src/components/graph/TransformPane.test.ts b/src/components/graph/TransformPane.test.ts new file mode 100644 index 000000000..a24704a24 --- /dev/null +++ b/src/components/graph/TransformPane.test.ts @@ -0,0 +1,435 @@ +import { mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick, ref } from 'vue' + +import TransformPane from './TransformPane.vue' + +// Mock the transform state composable +const mockTransformState = { + camera: ref({ x: 0, y: 0, z: 1 }), + transformStyle: ref({ + transform: 'scale(1) translate(0px, 0px)', + transformOrigin: '0 0' + }), + syncWithCanvas: vi.fn(), + canvasToScreen: vi.fn(), + screenToCanvas: vi.fn(), + isNodeInViewport: vi.fn() +} + +vi.mock('@/composables/element/useTransformState', () => ({ + useTransformState: () => mockTransformState +})) + +// Mock requestAnimationFrame/cancelAnimationFrame +global.requestAnimationFrame = vi.fn((cb) => { + setTimeout(cb, 16) + return 1 +}) +global.cancelAnimationFrame = vi.fn() + +describe('TransformPane', () => { + let wrapper: ReturnType + let mockCanvas: any + + beforeEach(() => { + vi.clearAllMocks() + + // Create mock canvas with LiteGraph interface + mockCanvas = { + canvas: { + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }, + ds: { + offset: [0, 0], + scale: 1 + } + } + + // Reset mock transform state + mockTransformState.camera.value = { x: 0, y: 0, z: 1 } + mockTransformState.transformStyle.value = { + transform: 'scale(1) translate(0px, 0px)', + transformOrigin: '0 0' + } + }) + + afterEach(() => { + if (wrapper) { + wrapper.unmount() + } + }) + + describe('component mounting', () => { + it('should mount successfully with minimal props', () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + expect(wrapper.exists()).toBe(true) + expect(wrapper.find('.transform-pane').exists()).toBe(true) + }) + + it('should apply transform style from composable', () => { + mockTransformState.transformStyle.value = { + transform: 'scale(2) translate(100px, 50px)', + transformOrigin: '0 0' + } + + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + const transformPane = wrapper.find('.transform-pane') + const style = transformPane.attributes('style') + expect(style).toContain('transform: scale(2) translate(100px, 50px)') + }) + + it('should render slot content', () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + }, + slots: { + default: '
Test Node
' + } + }) + + expect(wrapper.find('.test-content').exists()).toBe(true) + expect(wrapper.find('.test-content').text()).toBe('Test Node') + }) + }) + + describe('debug overlay', () => { + it('should not show debug overlay by default', () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + expect(wrapper.find('.viewport-debug-overlay').exists()).toBe(false) + }) + + it('should show debug overlay when enabled', () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas, + viewport: { width: 1920, height: 1080 }, + showDebugOverlay: true + } + }) + + expect(wrapper.find('.viewport-debug-overlay').exists()).toBe(true) + }) + + it('should display viewport dimensions in debug overlay', () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas, + viewport: { width: 1280, height: 720 }, + showDebugOverlay: true + } + }) + + const debugOverlay = wrapper.find('.viewport-debug-overlay') + expect(debugOverlay.text()).toContain('Viewport: 1280x720') + }) + + it('should include device pixel ratio in debug overlay', () => { + // Mock device pixel ratio + Object.defineProperty(window, 'devicePixelRatio', { + writable: true, + value: 2 + }) + + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas, + viewport: { width: 1920, height: 1080 }, + showDebugOverlay: true + } + }) + + const debugOverlay = wrapper.find('.viewport-debug-overlay') + expect(debugOverlay.text()).toContain('DPR: 2') + }) + }) + + describe('RAF synchronization', () => { + it('should start RAF sync on mount', async () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + await nextTick() + + // Should emit RAF status change to true + expect(wrapper.emitted('rafStatusChange')).toBeTruthy() + expect(wrapper.emitted('rafStatusChange')?.[0]).toEqual([true]) + }) + + it('should call syncWithCanvas during RAF updates', async () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + await nextTick() + + // Allow RAF to execute + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(mockTransformState.syncWithCanvas).toHaveBeenCalledWith(mockCanvas) + }) + + it('should emit transform update timing', async () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + await nextTick() + + // Allow RAF to execute + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(wrapper.emitted('transformUpdate')).toBeTruthy() + const updateEvent = wrapper.emitted('transformUpdate')?.[0] + expect(typeof updateEvent?.[0]).toBe('number') + expect(updateEvent?.[0]).toBeGreaterThanOrEqual(0) + }) + + it('should stop RAF sync on unmount', async () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + await nextTick() + wrapper.unmount() + + expect(wrapper.emitted('rafStatusChange')).toBeTruthy() + const events = wrapper.emitted('rafStatusChange') as any[] + expect(events[events.length - 1]).toEqual([false]) + expect(global.cancelAnimationFrame).toHaveBeenCalled() + }) + }) + + describe('canvas event listeners', () => { + it('should add event listeners to canvas on mount', async () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + await nextTick() + + expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith( + 'wheel', + expect.any(Function) + ) + expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith( + 'pointerdown', + expect.any(Function) + ) + expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith( + 'pointerup', + expect.any(Function) + ) + expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith( + 'pointercancel', + expect.any(Function) + ) + }) + + it('should remove event listeners on unmount', async () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + await nextTick() + wrapper.unmount() + + expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith( + 'wheel', + expect.any(Function) + ) + expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith( + 'pointerdown', + expect.any(Function) + ) + expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith( + 'pointerup', + expect.any(Function) + ) + expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith( + 'pointercancel', + expect.any(Function) + ) + }) + }) + + describe('interaction state management', () => { + it('should apply interacting class during interactions', async () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + // Simulate interaction start by checking internal state + // Note: This tests the CSS class application logic + const transformPane = wrapper.find('.transform-pane') + + // Initially should not have interacting class + expect(transformPane.classes()).not.toContain( + 'transform-pane--interacting' + ) + }) + + it('should handle pointer events for node delegation', async () => { + const mockElement = { + closest: vi.fn().mockReturnValue({ + getAttribute: vi.fn().mockReturnValue('node-123') + }) + } + + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + const transformPane = wrapper.find('.transform-pane') + + // Simulate pointer down with mock target + await transformPane.trigger('pointerdown', { + target: mockElement + }) + + expect(mockElement.closest).toHaveBeenCalledWith('[data-node-id]') + }) + }) + + describe('transform state integration', () => { + it('should provide transform utilities to child components', () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + // The component should provide transform state via Vue's provide/inject + // This is tested indirectly through the composable integration + expect(mockTransformState.syncWithCanvas).toBeDefined() + expect(mockTransformState.canvasToScreen).toBeDefined() + expect(mockTransformState.screenToCanvas).toBeDefined() + }) + }) + + describe('error handling', () => { + it('should handle null canvas gracefully', () => { + wrapper = mount(TransformPane, { + props: { + canvas: undefined + } + }) + + expect(wrapper.exists()).toBe(true) + expect(wrapper.find('.transform-pane').exists()).toBe(true) + }) + + it('should handle missing canvas properties', () => { + const incompleteCanvas = {} as any + + wrapper = mount(TransformPane, { + props: { + canvas: incompleteCanvas + } + }) + + expect(wrapper.exists()).toBe(true) + // Should not throw errors during mount + }) + }) + + describe('performance optimizations', () => { + it('should use contain CSS property for layout optimization', () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + const transformPane = wrapper.find('.transform-pane') + + // This test verifies the CSS contains the performance optimization + // Note: In JSDOM, computed styles might not reflect all CSS properties + expect(transformPane.element.className).toContain('transform-pane') + }) + + it('should disable pointer events on container but allow on children', () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + }, + slots: { + default: '
Test Node
' + } + }) + + const transformPane = wrapper.find('.transform-pane') + + // The CSS should handle pointer events optimization + // This is primarily a CSS concern, but we verify the structure + expect(transformPane.exists()).toBe(true) + expect(wrapper.find('[data-node-id="test"]').exists()).toBe(true) + }) + }) + + describe('viewport prop handling', () => { + it('should handle missing viewport prop', () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas, + showDebugOverlay: true + } + }) + + // Should not crash when viewport is undefined + expect(wrapper.exists()).toBe(true) + }) + + it('should update debug overlay when viewport changes', async () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas, + viewport: { width: 800, height: 600 }, + showDebugOverlay: true + } + }) + + expect(wrapper.text()).toContain('800x600') + + await wrapper.setProps({ + viewport: { width: 1920, height: 1080 } + }) + + expect(wrapper.text()).toContain('1920x1080') + }) + }) +})