diff --git a/CHANGELOG.md b/CHANGELOG.md index 96dbeb34c4..f99b9a2db7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ and this project adheres to ### Added +- Show collaborators mouse pointers on the workflow canvas + [#3810](https://github.com/OpenFn/lightning/issues/3810) + ### Changed - Default failure notifications for project users are now disabled to minimize diff --git a/assets/js/collaborative-editor/components/diagram/CollaborativeWorkflowDiagram.tsx b/assets/js/collaborative-editor/components/diagram/CollaborativeWorkflowDiagram.tsx index ced33d7cfe..8ee5149100 100644 --- a/assets/js/collaborative-editor/components/diagram/CollaborativeWorkflowDiagram.tsx +++ b/assets/js/collaborative-editor/components/diagram/CollaborativeWorkflowDiagram.tsx @@ -3,18 +3,18 @@ */ import { ReactFlowProvider } from '@xyflow/react'; -import { useMemo, useState, useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - useHistoryPanelCollapsed, useEditorPreferencesCommands, + useHistoryPanelCollapsed, } from '../../hooks/useEditorPreferences'; import { useHistory, - useHistoryLoading, - useHistoryError, - useHistoryCommands, useHistoryChannelConnected, + useHistoryCommands, + useHistoryError, + useHistoryLoading, useRunSteps, } from '../../hooks/useHistory'; import { useIsNewWorkflow } from '../../hooks/useSessionContext'; @@ -168,7 +168,7 @@ export function CollaborativeWorkflowDiagram({ forceFit={true} showAiAssistant={false} inspectorId={inspectorId} - containerEl={containerRef.current} + containerEl={containerRef.current!} runSteps={currentRunSteps} /> diff --git a/assets/js/collaborative-editor/components/diagram/PointerTrackerViewer.tsx b/assets/js/collaborative-editor/components/diagram/PointerTrackerViewer.tsx new file mode 100644 index 0000000000..3598d82d79 --- /dev/null +++ b/assets/js/collaborative-editor/components/diagram/PointerTrackerViewer.tsx @@ -0,0 +1,55 @@ +import { useViewport } from '@xyflow/react'; +import { useCallback, useEffect } from 'react'; + +import { useAwarenessCommands } from '#/collaborative-editor/hooks/useAwareness'; + +import { normalizePointerPosition } from './normalizePointer'; +import { RemoteCursors } from './RemoteCursor'; + +export function PointerTrackerViewer({ + containerEl: container, +}: { + containerEl: HTMLDivElement; +}) { + const { updateLocalCursor } = useAwarenessCommands(); + const { x: tx, y: ty, zoom: tzoom } = useViewport(); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!container) return; + const bounds = container.getBoundingClientRect(); + + const clientXRelativeToPane = e.clientX - bounds.left; + const clientYRelativeToPane = e.clientY - bounds.top; + + const normPosition = normalizePointerPosition( + { + x: clientXRelativeToPane, + y: clientYRelativeToPane, + }, + [tx, ty, tzoom] + ); + + updateLocalCursor({ x: normPosition.x, y: normPosition.y }); + }, + [updateLocalCursor, container, tx, ty, tzoom] + ); + + const handleMouseLeave = useCallback(() => { + updateLocalCursor(null); + }, [updateLocalCursor]); + + useEffect(() => { + if (!container) return; + + container.addEventListener('mousemove', handleMouseMove); + container.addEventListener('mouseleave', handleMouseLeave); + + return () => { + container.removeEventListener('mousemove', handleMouseMove); + container.removeEventListener('mouseleave', handleMouseLeave); + }; + }, [handleMouseMove, handleMouseLeave, container]); + + return ; +} diff --git a/assets/js/collaborative-editor/components/diagram/RemoteCursor.tsx b/assets/js/collaborative-editor/components/diagram/RemoteCursor.tsx new file mode 100644 index 0000000000..d41c1ef050 --- /dev/null +++ b/assets/js/collaborative-editor/components/diagram/RemoteCursor.tsx @@ -0,0 +1,107 @@ +import { useViewport } from '@xyflow/react'; +import { useMemo } from 'react'; + +import { cn } from '../../../utils/cn'; +import { useRemoteUsers } from '../../hooks/useAwareness'; + +import { denormalizePointerPosition } from './normalizePointer'; + +interface RemoteCursor { + clientId: number; + name: string; + color: string; + x: number; + y: number; +} + +export function RemoteCursors() { + const remoteUsers = useRemoteUsers(); + const { x: tx, y: ty, zoom: tzoom } = useViewport(); + + const cursors = useMemo(() => { + return remoteUsers + .filter(user => user.cursor) + .map(user => { + const screenPos = denormalizePointerPosition( + { + x: user.cursor!.x, + y: user.cursor!.y, + }, + [tx, ty, tzoom] + ); + + return { + clientId: user.clientId, + name: user.user.name, + color: user.user.color, + x: screenPos.x, + y: screenPos.y, + }; + }); + }, [remoteUsers, tx, ty, tzoom]); + + if (cursors.length === 0) { + return null; + } + + return ( +
+ {cursors.map(cursor => ( + + ))} +
+ ); +} + +interface RemoteCursorProps { + name: string; + color: string; + x: number; + y: number; +} + +function RemoteCursor({ name, color, x, y }: RemoteCursorProps) { + return ( +
+ {/* Cursor pointer (SVG arrow) */} + + + + {/* User name label */} +
+ {name} +
+
+ ); +} diff --git a/assets/js/collaborative-editor/components/diagram/WorkflowDiagram.tsx b/assets/js/collaborative-editor/components/diagram/WorkflowDiagram.tsx index 476e785f8a..bfb8fe93e3 100644 --- a/assets/js/collaborative-editor/components/diagram/WorkflowDiagram.tsx +++ b/assets/js/collaborative-editor/components/diagram/WorkflowDiagram.tsx @@ -47,11 +47,11 @@ import type { RunInfo } from '#/workflow-store/store'; import { createEmptyRunInfo } from '../../utils/runStepsTransformer'; import { AdaptorSelectionModal } from '../AdaptorSelectionModal'; -import { useInspectorOverlap } from './useInspectorOverlap'; +import { PointerTrackerViewer } from './PointerTrackerViewer'; type WorkflowDiagramProps = { el?: HTMLElement | null; - containerEl?: HTMLElement | null; + containerEl: HTMLElement; selection: string | null; onSelectionChange: (id: string | null) => void; forceFit?: boolean; @@ -110,13 +110,7 @@ export default function WorkflowDiagram(props: WorkflowDiagramProps) { const [flow, setFlow] = useState(null); // value of select in props seems same as select in store. // one in props is always set on initial render. (helps with refresh) - const { - selection, - onSelectionChange, - containerEl: el, - inspectorId, - runSteps, - } = props; + const { selection, onSelectionChange, containerEl: el, runSteps } = props; // Get Y.Doc workflow store for placeholder operations const workflowStore = useWorkflowStoreContext(); @@ -914,6 +908,7 @@ export default function WorkflowDiagram(props: WorkflowDiagramProps) { className="border-2 border-gray-200" nodeComponent={MiniMapNode} /> + diff --git a/assets/js/collaborative-editor/components/diagram/normalizePointer.ts b/assets/js/collaborative-editor/components/diagram/normalizePointer.ts new file mode 100644 index 0000000000..b5feb7fe1e --- /dev/null +++ b/assets/js/collaborative-editor/components/diagram/normalizePointer.ts @@ -0,0 +1,29 @@ +import type { XYPosition, Transform } from '@xyflow/react'; + +// functions for normalizing pointer positions based on zoom and pan. +// I was fustrated by the built-in screentoflowposition and its inverse provided by reactflow. +// credit: https://github.com/xyflow/xyflow/issues/3771#issuecomment-1880103788 + +export const denormalizePointerPosition = ( + { x, y }: XYPosition, + [tx, ty, tScale]: Transform +): XYPosition => { + const position: XYPosition = { + x: x * tScale + tx, + y: y * tScale + ty, + }; + + return position; +}; + +export const normalizePointerPosition = ( + { x, y }: XYPosition, + [tx, ty, tScale]: Transform +): XYPosition => { + const position: XYPosition = { + x: (x - tx) / tScale, + y: (y - ty) / tScale, + }; + + return position; +}; diff --git a/assets/test/collaborative-editor/components/diagram/PointerTrackerViewer.test.tsx b/assets/test/collaborative-editor/components/diagram/PointerTrackerViewer.test.tsx new file mode 100644 index 0000000000..3acd2d0465 --- /dev/null +++ b/assets/test/collaborative-editor/components/diagram/PointerTrackerViewer.test.tsx @@ -0,0 +1,638 @@ +/** + * PointerTrackerViewer Component Tests + * + * Tests for the PointerTrackerViewer component that tracks mouse movements + * on the workflow diagram and broadcasts cursor position to other users. + * + * Test coverage: + * - Mouse movement tracking and cursor updates + * - Mouse leave detection and cursor clearing + * - Viewport transform handling + * - Event listener lifecycle + * - Coordinate normalization integration + * - Edge cases and boundary conditions + */ + +import { fireEvent, render } from '@testing-library/react'; +import { ReactFlowProvider } from '@xyflow/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { Awareness } from 'y-protocols/awareness'; +import * as Y from 'yjs'; + +import { PointerTrackerViewer } from '../../../../js/collaborative-editor/components/diagram/PointerTrackerViewer'; +import { StoreContext } from '../../../../js/collaborative-editor/contexts/StoreProvider'; +import type { AwarenessStoreInstance } from '../../../../js/collaborative-editor/stores/createAwarenessStore'; +import { createAwarenessStore } from '../../../../js/collaborative-editor/stores/createAwarenessStore'; + +// Mock the @xyflow/react hooks +vi.mock('@xyflow/react', async () => { + const actual = await vi.importActual('@xyflow/react'); + return { + ...actual, + useViewport: vi.fn(() => ({ x: 0, y: 0, zoom: 1 })), + }; +}); + +describe('PointerTrackerViewer', () => { + let awarenessStore: AwarenessStoreInstance; + let mockAwareness: Awareness; + let ydoc: Y.Doc; + let containerEl: HTMLDivElement; + let updateLocalCursorSpy: ReturnType; + + beforeEach(() => { + awarenessStore = createAwarenessStore(); + ydoc = new Y.Doc(); + mockAwareness = new Awareness(ydoc); + + // Initialize the awareness store + awarenessStore.initializeAwareness(mockAwareness, { + id: 'local-user', + name: 'Local User', + email: 'local@example.com', + color: '#CCCCCC', + }); + + updateLocalCursorSpy = vi.spyOn(awarenessStore, 'updateLocalCursor'); + + // Create a container element for the component + containerEl = document.createElement('div'); + containerEl.style.width = '800px'; + containerEl.style.height = '600px'; + containerEl.style.position = 'relative'; + document.body.appendChild(containerEl); + + // Mock getBoundingClientRect for the container + vi.spyOn(containerEl, 'getBoundingClientRect').mockReturnValue({ + left: 100, + top: 50, + right: 900, + bottom: 650, + width: 800, + height: 600, + x: 100, + y: 50, + toJSON: () => {}, + }); + }); + + afterEach(() => { + document.body.removeChild(containerEl); + vi.restoreAllMocks(); + }); + + const renderWithProviders = (component: React.ReactElement) => { + return render( + + {component} + + ); + }; + + describe('mouse movement tracking', () => { + test('updates local cursor on mouse move', () => { + renderWithProviders(); + + // Simulate mouse move at screen position (200, 150) + // Relative to container: (200 - 100, 150 - 50) = (100, 100) + fireEvent.mouseMove(containerEl, { + clientX: 200, + clientY: 150, + }); + + expect(updateLocalCursorSpy).toHaveBeenCalledWith({ x: 100, y: 100 }); + }); + + test('tracks multiple mouse movements', () => { + renderWithProviders(); + + // First movement + fireEvent.mouseMove(containerEl, { + clientX: 200, + clientY: 150, + }); + + expect(updateLocalCursorSpy).toHaveBeenCalledWith({ x: 100, y: 100 }); + + // Second movement + fireEvent.mouseMove(containerEl, { + clientX: 300, + clientY: 250, + }); + + expect(updateLocalCursorSpy).toHaveBeenCalledWith({ x: 200, y: 200 }); + + expect(updateLocalCursorSpy).toHaveBeenCalledTimes(2); + }); + + test('handles mouse move at container origin', () => { + renderWithProviders(); + + // Mouse at top-left corner of container + fireEvent.mouseMove(containerEl, { + clientX: 100, + clientY: 50, + }); + + expect(updateLocalCursorSpy).toHaveBeenCalledWith({ x: 0, y: 0 }); + }); + + test('handles mouse move at container boundaries', () => { + renderWithProviders(); + + // Bottom-right corner + fireEvent.mouseMove(containerEl, { + clientX: 900, + clientY: 650, + }); + + expect(updateLocalCursorSpy).toHaveBeenCalledWith({ x: 800, y: 600 }); + }); + + test('handles mouse move outside container bounds', () => { + renderWithProviders(); + + // Move beyond container (this can happen during drag) + fireEvent.mouseMove(containerEl, { + clientX: 1000, + clientY: 700, + }); + + // Should still calculate relative position (even if negative or beyond bounds) + expect(updateLocalCursorSpy).toHaveBeenCalledWith({ x: 900, y: 650 }); + }); + + test('handles negative relative positions', () => { + renderWithProviders(); + + // Move before container start + fireEvent.mouseMove(containerEl, { + clientX: 50, + clientY: 25, + }); + + expect(updateLocalCursorSpy).toHaveBeenCalledWith({ x: -50, y: -25 }); + }); + + test('handles fractional pixel positions', () => { + renderWithProviders(); + + fireEvent.mouseMove(containerEl, { + clientX: 150.5, + clientY: 100.7, + }); + + expect(updateLocalCursorSpy).toHaveBeenCalledWith({ + x: 50.5, + y: 50.7, + }); + }); + }); + + describe('mouse leave handling', () => { + test('clears cursor on mouse leave', () => { + renderWithProviders(); + + // First move the mouse + fireEvent.mouseMove(containerEl, { + clientX: 200, + clientY: 150, + }); + + expect(updateLocalCursorSpy).toHaveBeenCalledWith({ x: 100, y: 100 }); + + // Then leave + fireEvent.mouseLeave(containerEl); + + expect(updateLocalCursorSpy).toHaveBeenCalledWith(null); + }); + + test('cursor can be re-established after leaving', () => { + renderWithProviders(); + + // Move + fireEvent.mouseMove(containerEl, { + clientX: 200, + clientY: 150, + }); + + // Leave + fireEvent.mouseLeave(containerEl); + + // Move again + fireEvent.mouseMove(containerEl, { + clientX: 300, + clientY: 250, + }); + + expect(updateLocalCursorSpy).toHaveBeenLastCalledWith({ x: 200, y: 200 }); + }); + }); + + describe('viewport transform integration', () => { + test('applies viewport pan to cursor position', async () => { + const { useViewport } = await import('@xyflow/react'); + + // Set viewport with pan offset + vi.mocked(useViewport).mockReturnValue({ x: 50, y: 30, zoom: 1 }); + + renderWithProviders(); + + // Mouse at (200, 150) screen coords = (100, 100) container-relative + fireEvent.mouseMove(containerEl, { + clientX: 200, + clientY: 150, + }); + + // With pan (50, 30) and no zoom: + // normalized = ((100 - 50) / 1, (100 - 30) / 1) = (50, 70) + expect(updateLocalCursorSpy).toHaveBeenCalledWith({ x: 50, y: 70 }); + }); + + test('applies viewport zoom to cursor position', async () => { + const { useViewport } = await import('@xyflow/react'); + + // Set viewport with 2x zoom + vi.mocked(useViewport).mockReturnValue({ x: 0, y: 0, zoom: 2 }); + + renderWithProviders(); + + // Mouse at (200, 150) screen coords = (100, 100) container-relative + fireEvent.mouseMove(containerEl, { + clientX: 200, + clientY: 150, + }); + + // With 2x zoom: normalized = (100 / 2, 100 / 2) = (50, 50) + expect(updateLocalCursorSpy).toHaveBeenCalledWith({ x: 50, y: 50 }); + }); + + test('applies combined pan and zoom', async () => { + const { useViewport } = await import('@xyflow/react'); + + // Pan and zoom + vi.mocked(useViewport).mockReturnValue({ x: 50, y: 30, zoom: 2 }); + + renderWithProviders(); + + fireEvent.mouseMove(containerEl, { + clientX: 200, + clientY: 150, + }); + + // Container-relative: (100, 100) + // With pan and zoom: ((100 - 50) / 2, (100 - 30) / 2) = (25, 35) + expect(updateLocalCursorSpy).toHaveBeenCalledWith({ x: 25, y: 35 }); + }); + + test('handles viewport changes reactively', async () => { + const { useViewport } = await import('@xyflow/react'); + + // Start with no transform + vi.mocked(useViewport).mockReturnValue({ x: 0, y: 0, zoom: 1 }); + + const { rerender } = renderWithProviders( + + ); + + fireEvent.mouseMove(containerEl, { + clientX: 200, + clientY: 150, + }); + + expect(updateLocalCursorSpy).toHaveBeenCalledWith({ x: 100, y: 100 }); + + // Change viewport + vi.mocked(useViewport).mockReturnValue({ x: 50, y: 50, zoom: 2 }); + + rerender( + + + + + + ); + + // Move mouse again with new viewport + fireEvent.mouseMove(containerEl, { + clientX: 200, + clientY: 150, + }); + + // New calculation: ((100 - 50) / 2, (100 - 50) / 2) = (25, 25) + expect(updateLocalCursorSpy).toHaveBeenCalledWith({ x: 25, y: 25 }); + }); + + test('handles extreme zoom levels', async () => { + const { useViewport } = await import('@xyflow/react'); + + vi.mocked(useViewport).mockReturnValue({ x: 0, y: 0, zoom: 10 }); + + renderWithProviders(); + + fireEvent.mouseMove(containerEl, { + clientX: 200, + clientY: 150, + }); + + // (100 / 10, 100 / 10) = (10, 10) + expect(updateLocalCursorSpy).toHaveBeenCalledWith({ x: 10, y: 10 }); + }); + + test('handles zoom out (scale < 1)', async () => { + const { useViewport } = await import('@xyflow/react'); + + vi.mocked(useViewport).mockReturnValue({ x: 0, y: 0, zoom: 0.5 }); + + renderWithProviders(); + + fireEvent.mouseMove(containerEl, { + clientX: 200, + clientY: 150, + }); + + // (100 / 0.5, 100 / 0.5) = (200, 200) + expect(updateLocalCursorSpy).toHaveBeenCalledWith({ x: 200, y: 200 }); + }); + + test('handles negative pan offsets', async () => { + const { useViewport } = await import('@xyflow/react'); + + vi.mocked(useViewport).mockReturnValue({ x: -50, y: -30, zoom: 1 }); + + renderWithProviders(); + + fireEvent.mouseMove(containerEl, { + clientX: 200, + clientY: 150, + }); + + // ((100 - (-50)) / 1, (100 - (-30)) / 1) = (150, 130) + expect(updateLocalCursorSpy).toHaveBeenCalledWith({ x: 150, y: 130 }); + }); + }); + + describe('event listener lifecycle', () => { + test('adds event listeners on mount', () => { + const addEventListenerSpy = vi.spyOn(containerEl, 'addEventListener'); + + renderWithProviders(); + + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'mousemove', + expect.any(Function) + ); + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'mouseleave', + expect.any(Function) + ); + }); + + test('removes event listeners on unmount', () => { + const removeEventListenerSpy = vi.spyOn( + containerEl, + 'removeEventListener' + ); + + const { unmount } = renderWithProviders( + + ); + + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'mousemove', + expect.any(Function) + ); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'mouseleave', + expect.any(Function) + ); + }); + + test('cleans up and re-adds listeners when container changes', () => { + const { rerender } = renderWithProviders( + + ); + + const removeEventListenerSpy = vi.spyOn( + containerEl, + 'removeEventListener' + ); + + // Create new container + const newContainerEl = document.createElement('div'); + newContainerEl.style.width = '800px'; + newContainerEl.style.height = '600px'; + document.body.appendChild(newContainerEl); + + const addEventListenerSpy = vi.spyOn(newContainerEl, 'addEventListener'); + + rerender( + + + + + + ); + + // Should remove from old container + expect(removeEventListenerSpy).toHaveBeenCalled(); + + // Should add to new container + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'mousemove', + expect.any(Function) + ); + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'mouseleave', + expect.any(Function) + ); + + document.body.removeChild(newContainerEl); + }); + + test('handles rapid viewport updates without losing listeners', async () => { + const { useViewport } = await import('@xyflow/react'); + + vi.mocked(useViewport).mockReturnValue({ x: 0, y: 0, zoom: 1 }); + + const { rerender } = renderWithProviders( + + ); + + // Rapidly change viewport multiple times + for (let i = 0; i < 10; i++) { + vi.mocked(useViewport).mockReturnValue({ + x: i * 10, + y: i * 10, + zoom: 1, + }); + + rerender( + + + + + + ); + } + + // Event listeners should still work + fireEvent.mouseMove(containerEl, { + clientX: 200, + clientY: 150, + }); + + expect(updateLocalCursorSpy).toHaveBeenCalled(); + }); + }); + + describe('edge cases', () => { + test('handles container with zero bounds', () => { + const zeroBoundsContainer = document.createElement('div'); + document.body.appendChild(zeroBoundsContainer); + + vi.spyOn(zeroBoundsContainer, 'getBoundingClientRect').mockReturnValue({ + left: 0, + top: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON: () => {}, + }); + + renderWithProviders( + + ); + + fireEvent.mouseMove(zeroBoundsContainer, { + clientX: 100, + clientY: 100, + }); + + // Should still call updateLocalCursor + expect(updateLocalCursorSpy).toHaveBeenCalled(); + + document.body.removeChild(zeroBoundsContainer); + }); + + test('handles very large container', () => { + const largeBoundsContainer = document.createElement('div'); + document.body.appendChild(largeBoundsContainer); + + vi.spyOn(largeBoundsContainer, 'getBoundingClientRect').mockReturnValue({ + left: 0, + top: 0, + right: 10000, + bottom: 10000, + width: 10000, + height: 10000, + x: 0, + y: 0, + toJSON: () => {}, + }); + + renderWithProviders( + + ); + + fireEvent.mouseMove(largeBoundsContainer, { + clientX: 5000, + clientY: 5000, + }); + + expect(updateLocalCursorSpy).toHaveBeenCalledWith({ x: 5000, y: 5000 }); + + document.body.removeChild(largeBoundsContainer); + }); + + test('handles container positioned off-screen', () => { + const offScreenContainer = document.createElement('div'); + document.body.appendChild(offScreenContainer); + + vi.spyOn(offScreenContainer, 'getBoundingClientRect').mockReturnValue({ + left: -1000, + top: -1000, + right: -200, + bottom: -400, + width: 800, + height: 600, + x: -1000, + y: -1000, + toJSON: () => {}, + }); + + renderWithProviders( + + ); + + fireEvent.mouseMove(offScreenContainer, { + clientX: -900, + clientY: -900, + }); + + // -900 - (-1000) = 100 + expect(updateLocalCursorSpy).toHaveBeenCalledWith({ x: 100, y: 100 }); + + document.body.removeChild(offScreenContainer); + }); + + test('handles rapid mouse movements', () => { + renderWithProviders(); + + // Simulate 100 rapid movements + for (let i = 0; i < 100; i++) { + fireEvent.mouseMove(containerEl, { + clientX: 100 + i, + clientY: 50 + i, + }); + } + + expect(updateLocalCursorSpy).toHaveBeenCalledTimes(100); + }); + + test('handles mouse events with no clientX/clientY (edge case)', () => { + renderWithProviders(); + + // Some browsers might send events without proper coordinates + fireEvent.mouseMove(containerEl, {} as any); + + // Should handle gracefully (NaN coordinates) + expect(updateLocalCursorSpy).toHaveBeenCalled(); + }); + }); + + describe('performance considerations', () => { + test('handles awareness store updates during mouse movement', async () => { + renderWithProviders(); + + // Move mouse + fireEvent.mouseMove(containerEl, { + clientX: 200, + clientY: 150, + }); + + // Update awareness store while mouse is moving + // Simulate a remote user appearing + const remoteState = { + user: { id: 'user-1', name: 'Alice', color: '#FF0000' }, + cursor: { x: 50, y: 50 }, + }; + mockAwareness.states.set(999, remoteState); + awarenessStore._internal.handleAwarenessChange(); + + // Continue moving + fireEvent.mouseMove(containerEl, { + clientX: 300, + clientY: 250, + }); + + // Should still work correctly + expect(updateLocalCursorSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/assets/test/collaborative-editor/components/diagram/RemoteCursor.test.tsx b/assets/test/collaborative-editor/components/diagram/RemoteCursor.test.tsx new file mode 100644 index 0000000000..1e56be6258 --- /dev/null +++ b/assets/test/collaborative-editor/components/diagram/RemoteCursor.test.tsx @@ -0,0 +1,711 @@ +/** + * RemoteCursor Component Tests + * + * Tests for the RemoteCursors and RemoteCursor components that render + * collaborative user cursors on the workflow diagram canvas. + * + * Test coverage: + * - Cursor rendering with awareness data + * - Position calculation with viewport transforms + * - Cursor visibility and filtering + * - User name labels and colors + * - Viewport transform reactivity + */ + +import { describe, expect, test, beforeEach, vi } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +import { ReactFlowProvider } from '@xyflow/react'; +import { Awareness } from 'y-protocols/awareness'; +import * as Y from 'yjs'; + +import { StoreContext } from '../../../../js/collaborative-editor/contexts/StoreProvider'; +import type { AwarenessStoreInstance } from '../../../../js/collaborative-editor/stores/createAwarenessStore'; +import { createAwarenessStore } from '../../../../js/collaborative-editor/stores/createAwarenessStore'; +import type { AwarenessUser } from '../../../../js/collaborative-editor/types/awareness'; +import { RemoteCursors } from '../../../../js/collaborative-editor/components/diagram/RemoteCursor'; + +// Mock the @xyflow/react hooks +vi.mock('@xyflow/react', async () => { + const actual = await vi.importActual('@xyflow/react'); + return { + ...actual, + useViewport: vi.fn(() => ({ x: 0, y: 0, zoom: 1 })), + }; +}); + +describe('RemoteCursors', () => { + let awarenessStore: AwarenessStoreInstance; + let mockAwareness: Awareness; + let ydoc: Y.Doc; + + beforeEach(() => { + awarenessStore = createAwarenessStore(); + ydoc = new Y.Doc(); + mockAwareness = new Awareness(ydoc); + + // Initialize the awareness store + awarenessStore.initializeAwareness(mockAwareness, { + id: 'local-user', + name: 'Local User', + email: 'local@example.com', + color: '#CCCCCC', + }); + }); + + // Helper to set awareness states for testing + const setAwarenessUsers = (users: AwarenessUser[]) => { + // Clear all existing remote states (not local) + const states = mockAwareness.getStates(); + const statesToRemove: number[] = []; + states.forEach((_, clientId) => { + if (clientId !== mockAwareness.clientID) { + statesToRemove.push(clientId); + } + }); + + // Remove old states + statesToRemove.forEach(clientId => { + mockAwareness.states.delete(clientId); + mockAwareness.meta.delete(clientId); + }); + + // Set new states + users.forEach(user => { + const state: Record = { + user: user.user, + }; + if (user.cursor) { + state.cursor = user.cursor; + } + if (user.selection) { + state.selection = user.selection; + } + if (user.lastSeen) { + state.lastSeen = user.lastSeen; + } + + // Set remote state + mockAwareness.states.set(user.clientId, state); + mockAwareness.meta.set(user.clientId, { + clock: Date.now(), + lastUpdated: Date.now(), + }); + }); + + // Trigger awareness change + awarenessStore._internal.handleAwarenessChange(); + }; + + const renderWithProviders = (component: React.ReactElement) => { + return render( + + {component} + + ); + }; + + test('renders nothing when no remote users have cursors', () => { + setAwarenessUsers([]); + + const { container } = renderWithProviders(); + + expect(container.firstChild).toBeNull(); + }); + + test('renders nothing when remote users exist but have no cursor data', () => { + const users: AwarenessUser[] = [ + { + clientId: 1, + user: { + id: 'user-1', + name: 'Alice', + color: '#FF0000', + }, + // No cursor property + }, + { + clientId: 2, + user: { + id: 'user-2', + name: 'Bob', + color: '#00FF00', + }, + // No cursor property + }, + ]; + + setAwarenessUsers(users); + + const { container } = renderWithProviders(); + + expect(container.firstChild).toBeNull(); + }); + + test('renders cursor for single remote user with cursor data', () => { + const users: AwarenessUser[] = [ + { + clientId: 1, + user: { + id: 'user-1', + name: 'Alice', + color: '#FF0000', + }, + cursor: { x: 100, y: 200 }, + }, + ]; + + setAwarenessUsers(users); + + const { container } = renderWithProviders(); + + // Should render the wrapper div and cursor + expect(container.querySelector('.absolute.inset-0')).toBeInTheDocument(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + + test('renders multiple cursors for multiple users', () => { + const users: AwarenessUser[] = [ + { + clientId: 1, + user: { + id: 'user-1', + name: 'Alice', + color: '#FF0000', + }, + cursor: { x: 100, y: 200 }, + }, + { + clientId: 2, + user: { + id: 'user-2', + name: 'Bob', + color: '#00FF00', + }, + cursor: { x: 300, y: 400 }, + }, + { + clientId: 3, + user: { + id: 'user-3', + name: 'Charlie', + color: '#0000FF', + }, + cursor: { x: 500, y: 600 }, + }, + ]; + + setAwarenessUsers(users); + + renderWithProviders(); + + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + expect(screen.getByText('Charlie')).toBeInTheDocument(); + }); + + test('filters out users without cursor data', () => { + const users: AwarenessUser[] = [ + { + clientId: 1, + user: { + id: 'user-1', + name: 'Alice', + color: '#FF0000', + }, + cursor: { x: 100, y: 200 }, + }, + { + clientId: 2, + user: { + id: 'user-2', + name: 'Bob', + color: '#00FF00', + }, + // No cursor + }, + { + clientId: 3, + user: { + id: 'user-3', + name: 'Charlie', + color: '#0000FF', + }, + cursor: { x: 500, y: 600 }, + }, + ]; + + setAwarenessUsers(users); + + renderWithProviders(); + + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.queryByText('Bob')).not.toBeInTheDocument(); + expect(screen.getByText('Charlie')).toBeInTheDocument(); + }); + + test('applies user color to cursor', () => { + const users: AwarenessUser[] = [ + { + clientId: 1, + user: { + id: 'user-1', + name: 'Alice', + color: '#FF0000', + }, + cursor: { x: 100, y: 200 }, + }, + ]; + + setAwarenessUsers(users); + + const { container } = renderWithProviders(); + + // Check that the SVG path has the fill color + const svg = container.querySelector('svg path'); + expect(svg).toHaveAttribute('fill', '#FF0000'); + + // Check that the label div has the background color + const labelDiv = container.querySelector('.text-xs.font-medium.text-white'); + expect(labelDiv).toHaveStyle({ backgroundColor: 'rgb(255, 0, 0)' }); + }); + + test('cursor has correct CSS classes for pointer-events-none', () => { + const users: AwarenessUser[] = [ + { + clientId: 1, + user: { + id: 'user-1', + name: 'Alice', + color: '#FF0000', + }, + cursor: { x: 100, y: 200 }, + }, + ]; + + setAwarenessUsers(users); + + const { container } = renderWithProviders(); + + // The wrapper should have pointer-events-none to not interfere with canvas + const wrapper = container.querySelector('.pointer-events-none'); + expect(wrapper).toBeInTheDocument(); + expect(wrapper).toHaveClass('absolute', 'inset-0', 'z-50'); + }); + + test('cursor renders SVG arrow icon', () => { + const users: AwarenessUser[] = [ + { + clientId: 1, + user: { + id: 'user-1', + name: 'Alice', + color: '#FF0000', + }, + cursor: { x: 100, y: 200 }, + }, + ]; + + setAwarenessUsers(users); + + const { container } = renderWithProviders(); + + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + expect(svg).toHaveAttribute('width', '24'); + expect(svg).toHaveAttribute('height', '24'); + expect(svg).toHaveClass('drop-shadow-md'); + }); + + test('cursor label has correct text styling', () => { + const users: AwarenessUser[] = [ + { + clientId: 1, + user: { + id: 'user-1', + name: 'Alice', + color: '#FF0000', + }, + cursor: { x: 100, y: 200 }, + }, + ]; + + setAwarenessUsers(users); + + const { container } = renderWithProviders(); + + const labelDiv = container.querySelector('.text-xs.font-medium.text-white'); + expect(labelDiv).toHaveClass( + 'text-xs', + 'font-medium', + 'text-white', + 'whitespace-nowrap' + ); + }); + + test('uses clientId as React key', () => { + const users: AwarenessUser[] = [ + { + clientId: 123, + user: { + id: 'user-1', + name: 'Alice', + color: '#FF0000', + }, + cursor: { x: 100, y: 200 }, + }, + { + clientId: 456, + user: { + id: 'user-2', + name: 'Bob', + color: '#00FF00', + }, + cursor: { x: 300, y: 400 }, + }, + ]; + + setAwarenessUsers(users); + + const { container } = renderWithProviders(); + + // Check that we have two cursor elements rendered + const cursorDivs = container.querySelectorAll('.absolute.transition-all'); + expect(cursorDivs).toHaveLength(2); + }); + + test('handles rapid cursor updates', () => { + const users: AwarenessUser[] = [ + { + clientId: 1, + user: { + id: 'user-1', + name: 'Alice', + color: '#FF0000', + }, + cursor: { x: 100, y: 200 }, + }, + ]; + + setAwarenessUsers(users); + + const { rerender } = renderWithProviders(); + + expect(screen.getByText('Alice')).toBeInTheDocument(); + + // Update cursor position + const updatedUsers: AwarenessUser[] = [ + { + clientId: 1, + user: { + id: 'user-1', + name: 'Alice', + color: '#FF0000', + }, + cursor: { x: 150, y: 250 }, + }, + ]; + + setAwarenessUsers(updatedUsers); + rerender( + + + + + + ); + + // Cursor should still be rendered with same name + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + + test('removes cursor when user disconnects', () => { + const users: AwarenessUser[] = [ + { + clientId: 1, + user: { + id: 'user-1', + name: 'Alice', + color: '#FF0000', + }, + cursor: { x: 100, y: 200 }, + }, + { + clientId: 2, + user: { + id: 'user-2', + name: 'Bob', + color: '#00FF00', + }, + cursor: { x: 300, y: 400 }, + }, + ]; + + setAwarenessUsers(users); + + const { rerender } = renderWithProviders(); + + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + + // Remove Alice + const updatedUsers: AwarenessUser[] = [ + { + clientId: 2, + user: { + id: 'user-2', + name: 'Bob', + color: '#00FF00', + }, + cursor: { x: 300, y: 400 }, + }, + ]; + + act(() => { + setAwarenessUsers(updatedUsers); + rerender( + + + + + + ); + }); + + expect(screen.queryByText('Alice')).not.toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + }); + + test('handles cursor data with null values', () => { + const users: AwarenessUser[] = [ + { + clientId: 1, + user: { + id: 'user-1', + name: 'Alice', + color: '#FF0000', + }, + cursor: { x: 100, y: 200 }, + }, + { + clientId: 2, + user: { + id: 'user-2', + name: 'Bob', + color: '#00FF00', + }, + cursor: null as any, // Explicitly null cursor + }, + ]; + + setAwarenessUsers(users); + + renderWithProviders(); + + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.queryByText('Bob')).not.toBeInTheDocument(); + }); + + describe('viewport transform integration', () => { + test('recalculates positions when viewport changes', async () => { + const { useViewport } = await import('@xyflow/react'); + + // Set initial viewport + vi.mocked(useViewport).mockReturnValue({ x: 0, y: 0, zoom: 1 }); + + const users: AwarenessUser[] = [ + { + clientId: 1, + user: { + id: 'user-1', + name: 'Alice', + color: '#FF0000', + }, + cursor: { x: 100, y: 200 }, + }, + ]; + + setAwarenessUsers(users); + + const { rerender } = renderWithProviders(); + + expect(screen.getByText('Alice')).toBeInTheDocument(); + + // Change viewport (zoom in) + vi.mocked(useViewport).mockReturnValue({ x: 50, y: 50, zoom: 2 }); + + rerender( + + + + + + ); + + // Cursor should still be rendered (position calculation handled internally) + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + + test('handles negative viewport offsets', async () => { + const { useViewport } = await import('@xyflow/react'); + + vi.mocked(useViewport).mockReturnValue({ x: -100, y: -200, zoom: 1 }); + + const users: AwarenessUser[] = [ + { + clientId: 1, + user: { + id: 'user-1', + name: 'Alice', + color: '#FF0000', + }, + cursor: { x: 100, y: 200 }, + }, + ]; + + setAwarenessUsers(users); + + renderWithProviders(); + + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + + test('handles extreme zoom levels', async () => { + const { useViewport } = await import('@xyflow/react'); + + // Very zoomed in + vi.mocked(useViewport).mockReturnValue({ x: 0, y: 0, zoom: 10 }); + + const users: AwarenessUser[] = [ + { + clientId: 1, + user: { + id: 'user-1', + name: 'Alice', + color: '#FF0000', + }, + cursor: { x: 10, y: 20 }, + }, + ]; + + setAwarenessUsers(users); + + renderWithProviders(); + + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + }); + + describe('edge cases', () => { + test('handles empty user name', () => { + const users: AwarenessUser[] = [ + { + clientId: 1, + user: { + id: 'user-1', + name: '', + color: '#FF0000', + }, + cursor: { x: 100, y: 200 }, + }, + ]; + + setAwarenessUsers(users); + + const { container } = renderWithProviders(); + + // Should still render the cursor SVG + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + test('handles very long user names', () => { + const users: AwarenessUser[] = [ + { + clientId: 1, + user: { + id: 'user-1', + name: 'A'.repeat(100), + color: '#FF0000', + }, + cursor: { x: 100, y: 200 }, + }, + ]; + + setAwarenessUsers(users); + + const { container } = renderWithProviders(); + + // Find the label div directly with the text styling classes + const labelDiv = container.querySelector( + '.text-xs.font-medium.text-white' + ); + expect(labelDiv).toHaveClass('whitespace-nowrap'); + expect(labelDiv).toHaveTextContent('A'.repeat(100)); + }); + + test('handles coordinates at origin (0, 0)', () => { + const users: AwarenessUser[] = [ + { + clientId: 1, + user: { + id: 'user-1', + name: 'Alice', + color: '#FF0000', + }, + cursor: { x: 0, y: 0 }, + }, + ]; + + setAwarenessUsers(users); + + renderWithProviders(); + + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + + test('handles negative coordinates', () => { + const users: AwarenessUser[] = [ + { + clientId: 1, + user: { + id: 'user-1', + name: 'Alice', + color: '#FF0000', + }, + cursor: { x: -100, y: -200 }, + }, + ]; + + setAwarenessUsers(users); + + renderWithProviders(); + + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + + test('handles very large coordinates', () => { + const users: AwarenessUser[] = [ + { + clientId: 1, + user: { + id: 'user-1', + name: 'Alice', + color: '#FF0000', + }, + cursor: { x: 999999, y: 999999 }, + }, + ]; + + setAwarenessUsers(users); + + renderWithProviders(); + + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + }); +}); diff --git a/assets/test/collaborative-editor/components/diagram/normalizePointer.test.ts b/assets/test/collaborative-editor/components/diagram/normalizePointer.test.ts new file mode 100644 index 0000000000..473fea7d44 --- /dev/null +++ b/assets/test/collaborative-editor/components/diagram/normalizePointer.test.ts @@ -0,0 +1,289 @@ +/** + * normalizePointer Tests + * + * Tests for coordinate transformation functions used in collaborative cursors: + * - normalizePointerPosition: Converts screen coordinates to flow coordinates + * - denormalizePointerPosition: Converts flow coordinates back to screen coordinates + * + * These functions handle zoom and pan transformations for cursor positions + * in the ReactFlow canvas. + */ + +import { describe, expect, test } from 'vitest'; +import type { Transform } from '@xyflow/react'; + +import { + normalizePointerPosition, + denormalizePointerPosition, +} from '../../../../js/collaborative-editor/components/diagram/normalizePointer'; + +describe('normalizePointer', () => { + describe('normalizePointerPosition', () => { + test('converts screen coordinates to flow coordinates with no transform', () => { + const transform: Transform = [0, 0, 1]; // no pan, no zoom + const screenPos = { x: 100, y: 200 }; + + const result = normalizePointerPosition(screenPos, transform); + + expect(result).toEqual({ x: 100, y: 200 }); + }); + + test('handles positive pan offset', () => { + const transform: Transform = [50, 30, 1]; // pan right 50, down 30 + const screenPos = { x: 100, y: 200 }; + + const result = normalizePointerPosition(screenPos, transform); + + // When canvas is panned right 50, a screen position of 100 + // corresponds to flow position of 50 + expect(result).toEqual({ x: 50, y: 170 }); + }); + + test('handles negative pan offset', () => { + const transform: Transform = [-50, -30, 1]; // pan left 50, up 30 + const screenPos = { x: 100, y: 200 }; + + const result = normalizePointerPosition(screenPos, transform); + + expect(result).toEqual({ x: 150, y: 230 }); + }); + + test('handles zoom scaling', () => { + const transform: Transform = [0, 0, 2]; // 2x zoom + const screenPos = { x: 100, y: 200 }; + + const result = normalizePointerPosition(screenPos, transform); + + // With 2x zoom, screen position 100 corresponds to flow position 50 + expect(result).toEqual({ x: 50, y: 100 }); + }); + + test('handles zoom out (scale < 1)', () => { + const transform: Transform = [0, 0, 0.5]; // 0.5x zoom (zoomed out) + const screenPos = { x: 100, y: 200 }; + + const result = normalizePointerPosition(screenPos, transform); + + // With 0.5x zoom, screen position 100 corresponds to flow position 200 + expect(result).toEqual({ x: 200, y: 400 }); + }); + + test('handles combined pan and zoom', () => { + const transform: Transform = [50, 30, 2]; // pan right 50, down 30, 2x zoom + const screenPos = { x: 150, y: 230 }; + + const result = normalizePointerPosition(screenPos, transform); + + // First subtract pan: (150-50, 230-30) = (100, 200) + // Then divide by scale: (100/2, 200/2) = (50, 100) + expect(result).toEqual({ x: 50, y: 100 }); + }); + + test('handles origin point (0, 0)', () => { + const transform: Transform = [100, 50, 1.5]; + const screenPos = { x: 0, y: 0 }; + + const result = normalizePointerPosition(screenPos, transform); + + expect(result.x).toBeCloseTo(-66.67, 1); + expect(result.y).toBeCloseTo(-33.33, 1); + }); + + test('handles large coordinates', () => { + const transform: Transform = [0, 0, 1]; + const screenPos = { x: 10000, y: 10000 }; + + const result = normalizePointerPosition(screenPos, transform); + + expect(result).toEqual({ x: 10000, y: 10000 }); + }); + + test('handles negative screen coordinates', () => { + const transform: Transform = [0, 0, 1]; + const screenPos = { x: -50, y: -100 }; + + const result = normalizePointerPosition(screenPos, transform); + + expect(result).toEqual({ x: -50, y: -100 }); + }); + }); + + describe('denormalizePointerPosition', () => { + test('converts flow coordinates to screen coordinates with no transform', () => { + const transform: Transform = [0, 0, 1]; + const flowPos = { x: 100, y: 200 }; + + const result = denormalizePointerPosition(flowPos, transform); + + expect(result).toEqual({ x: 100, y: 200 }); + }); + + test('handles positive pan offset', () => { + const transform: Transform = [50, 30, 1]; + const flowPos = { x: 100, y: 200 }; + + const result = denormalizePointerPosition(flowPos, transform); + + // Flow position 100 with pan right 50 = screen position 150 + expect(result).toEqual({ x: 150, y: 230 }); + }); + + test('handles negative pan offset', () => { + const transform: Transform = [-50, -30, 1]; + const flowPos = { x: 100, y: 200 }; + + const result = denormalizePointerPosition(flowPos, transform); + + expect(result).toEqual({ x: 50, y: 170 }); + }); + + test('handles zoom scaling', () => { + const transform: Transform = [0, 0, 2]; + const flowPos = { x: 100, y: 200 }; + + const result = denormalizePointerPosition(flowPos, transform); + + // Flow position 100 with 2x zoom = screen position 200 + expect(result).toEqual({ x: 200, y: 400 }); + }); + + test('handles zoom out (scale < 1)', () => { + const transform: Transform = [0, 0, 0.5]; + const flowPos = { x: 100, y: 200 }; + + const result = denormalizePointerPosition(flowPos, transform); + + // Flow position 100 with 0.5x zoom = screen position 50 + expect(result).toEqual({ x: 50, y: 100 }); + }); + + test('handles combined pan and zoom', () => { + const transform: Transform = [50, 30, 2]; + const flowPos = { x: 50, y: 100 }; + + const result = denormalizePointerPosition(flowPos, transform); + + // First multiply by scale: (50*2, 100*2) = (100, 200) + // Then add pan: (100+50, 200+30) = (150, 230) + expect(result).toEqual({ x: 150, y: 230 }); + }); + + test('handles origin point (0, 0)', () => { + const transform: Transform = [100, 50, 1.5]; + const flowPos = { x: 0, y: 0 }; + + const result = denormalizePointerPosition(flowPos, transform); + + expect(result).toEqual({ x: 100, y: 50 }); + }); + + test('handles large coordinates', () => { + const transform: Transform = [0, 0, 1]; + const flowPos = { x: 10000, y: 10000 }; + + const result = denormalizePointerPosition(flowPos, transform); + + expect(result).toEqual({ x: 10000, y: 10000 }); + }); + + test('handles negative flow coordinates', () => { + const transform: Transform = [0, 0, 1]; + const flowPos = { x: -50, y: -100 }; + + const result = denormalizePointerPosition(flowPos, transform); + + expect(result).toEqual({ x: -50, y: -100 }); + }); + }); + + describe('round-trip transformations', () => { + test('normalize then denormalize returns original position', () => { + const transform: Transform = [50, 30, 1.5]; + const original = { x: 123.45, y: 678.9 }; + + const normalized = normalizePointerPosition(original, transform); + const result = denormalizePointerPosition(normalized, transform); + + expect(result.x).toBeCloseTo(original.x, 10); + expect(result.y).toBeCloseTo(original.y, 10); + }); + + test('denormalize then normalize returns original position', () => { + const transform: Transform = [100, -50, 0.75]; + const original = { x: 456.78, y: 123.45 }; + + const denormalized = denormalizePointerPosition(original, transform); + const result = normalizePointerPosition(denormalized, transform); + + expect(result.x).toBeCloseTo(original.x, 10); + expect(result.y).toBeCloseTo(original.y, 10); + }); + + test('round-trip with extreme zoom', () => { + const transform: Transform = [-200, 300, 5]; + const original = { x: 999.99, y: -555.55 }; + + const normalized = normalizePointerPosition(original, transform); + const result = denormalizePointerPosition(normalized, transform); + + expect(result.x).toBeCloseTo(original.x, 10); + expect(result.y).toBeCloseTo(original.y, 10); + }); + + test('round-trip with very small zoom', () => { + const transform: Transform = [10, 20, 0.1]; + const original = { x: 42.42, y: 84.84 }; + + const normalized = normalizePointerPosition(original, transform); + const result = denormalizePointerPosition(normalized, transform); + + expect(result.x).toBeCloseTo(original.x, 8); + expect(result.y).toBeCloseTo(original.y, 8); + }); + }); + + describe('edge cases', () => { + test('handles zero zoom gracefully (division by zero)', () => { + const transform: Transform = [0, 0, 0]; + const screenPos = { x: 100, y: 200 }; + + const result = normalizePointerPosition(screenPos, transform); + + // Division by zero should result in Infinity + expect(result.x).toBe(Infinity); + expect(result.y).toBe(Infinity); + }); + + test('handles very small scale values', () => { + const transform: Transform = [0, 0, 0.001]; + const screenPos = { x: 1, y: 1 }; + + const result = normalizePointerPosition(screenPos, transform); + + expect(result.x).toBe(1000); + expect(result.y).toBe(1000); + }); + + test('handles very large scale values', () => { + const transform: Transform = [0, 0, 1000]; + const screenPos = { x: 1000, y: 1000 }; + + const result = normalizePointerPosition(screenPos, transform); + + expect(result.x).toBe(1); + expect(result.y).toBe(1); + }); + + test('handles fractional pixel coordinates', () => { + const transform: Transform = [12.3456, 78.9012, 1.234]; + const screenPos = { x: 123.456, y: 789.012 }; + + const result = normalizePointerPosition(screenPos, transform); + + // (123.456 - 12.3456) / 1.234 = 90.04084... + // (789.012 - 78.9012) / 1.234 = 575.45446... + expect(result.x).toBeCloseTo(90.04084, 2); + expect(result.y).toBeCloseTo(575.45446, 2); + }); + }); +});