From a58a35459f49acd0df295485fe24c65041762b7c Mon Sep 17 00:00:00 2001 From: bymyself Date: Sat, 5 Jul 2025 00:05:25 -0700 Subject: [PATCH] [test] Add comprehensive tests for transform and spatial composables - Add useTransformState tests covering coordinate conversion, viewport culling - Add useSpatialIndex tests for QuadTree operations and spatial queries - Test camera state management and transform synchronization - Verify spatial indexing performance and correctness - Cover edge cases and error conditions for robust testing --- .../element/useTransformState.test.ts | 329 ++++++++++++ .../composables/graph/useSpatialIndex.test.ts | 483 ++++++++++++++++++ .../graph/useWidgetRenderer.test.ts | 141 +++++ 3 files changed, 953 insertions(+) create mode 100644 tests-ui/tests/composables/element/useTransformState.test.ts create mode 100644 tests-ui/tests/composables/graph/useSpatialIndex.test.ts create mode 100644 tests-ui/tests/composables/graph/useWidgetRenderer.test.ts diff --git a/tests-ui/tests/composables/element/useTransformState.test.ts b/tests-ui/tests/composables/element/useTransformState.test.ts new file mode 100644 index 000000000..acf22483b --- /dev/null +++ b/tests-ui/tests/composables/element/useTransformState.test.ts @@ -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 + + 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 + }) + }) +}) diff --git a/tests-ui/tests/composables/graph/useSpatialIndex.test.ts b/tests-ui/tests/composables/graph/useSpatialIndex.test.ts new file mode 100644 index 000000000..3329e762c --- /dev/null +++ b/tests-ui/tests/composables/graph/useSpatialIndex.test.ts @@ -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 + + 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') + }) + }) +}) diff --git a/tests-ui/tests/composables/graph/useWidgetRenderer.test.ts b/tests-ui/tests/composables/graph/useWidgetRenderer.test.ts new file mode 100644 index 000000000..92b652659 --- /dev/null +++ b/tests-ui/tests/composables/graph/useWidgetRenderer.test.ts @@ -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.TEXTAREA) + 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) + }) + }) +})