import { Mock, Mocked, vi } from 'vitest' import { Box } from '../../../primitives/Box' import { Vec } from '../../../primitives/Vec' import { Editor } from '../../Editor' import { EdgeScrollManager } from './EdgeScrollManager' // Mock the Editor class vi.mock('../../Editor') describe('EdgeScrollManager', () => { let editor: Mocked< Editor & { user: { getEdgeScrollSpeed: Mock } getCamera: Mock getCameraOptions: Mock getZoomLevel: Mock getViewportScreenBounds: Mock } > let edgeScrollManager: EdgeScrollManager let mockInputs: { _currentScreenPoint: Vec currentScreenPoint: Vec getCurrentScreenPoint(): Vec setCurrentScreenPoint(value: Vec): void _isDragging: boolean isDragging: boolean getIsDragging(): boolean setIsDragging(value: boolean): void _isPanning: boolean isPanning: boolean getIsPanning(): boolean setIsPanning(value: boolean): void } beforeEach(() => { // Create a mock inputs object with writable properties and getters mockInputs = { _currentScreenPoint: new Vec(500, 300), get currentScreenPoint() { return this._currentScreenPoint }, getCurrentScreenPoint() { return this._currentScreenPoint }, setCurrentScreenPoint(value: Vec) { this._currentScreenPoint = value }, _isDragging: true, get isDragging() { return this._isDragging }, getIsDragging() { return this._isDragging }, setIsDragging(value: boolean) { this._isDragging = value }, _isPanning: false, get isPanning() { return this._isPanning }, getIsPanning() { return this._isPanning }, setIsPanning(value: boolean) { this._isPanning = value }, } editor = { options: { edgeScrollDelay: 200, edgeScrollEaseDuration: 200, edgeScrollSpeed: 25, edgeScrollDistance: 8, coarsePointerWidth: 12, }, inputs: mockInputs as unknown as Editor['inputs'], user: { getEdgeScrollSpeed: vi.fn(() => 1), }, getViewportScreenBounds: vi.fn(() => new Box(0, 0, 1000, 600)), getInstanceState: vi.fn(() => ({ isCoarsePointer: false, insets: [false, false, false, false], // [top, right, bottom, left] })), getCameraOptions: vi.fn(() => ({ isLocked: false, panSpeed: 1, zoomSpeed: 1, zoomSteps: [1], wheelBehavior: 'pan' as const, })), getZoomLevel: vi.fn(() => 1), getCamera: vi.fn(() => new Vec(0, 0, 1)), setCamera: vi.fn(), } as unknown as Mocked< Editor & { user: { getEdgeScrollSpeed: Mock } getCamera: Mock getCameraOptions: Mock getZoomLevel: Mock getViewportScreenBounds: Mock } > edgeScrollManager = new EdgeScrollManager(editor) }) afterEach(() => { vi.clearAllMocks() }) describe('constructor and initialization', () => { it('should initialize with editor reference', () => { expect(edgeScrollManager.editor).toBe(editor) }) }) describe('basic edge scrolling behavior', () => { it('should not trigger edge scrolling when pointer is in center', () => { mockInputs.setCurrentScreenPoint(new Vec(500, 300)) edgeScrollManager.updateEdgeScrolling(16) expect(editor.setCamera).not.toHaveBeenCalled() }) it('should start edge scrolling when pointer is near edge', () => { mockInputs.setCurrentScreenPoint(new Vec(5, 300)) // Should not scroll immediately due to delay edgeScrollManager.updateEdgeScrolling(16) expect(editor.setCamera).not.toHaveBeenCalled() // Should scroll after delay edgeScrollManager.updateEdgeScrolling(200) expect(editor.setCamera).toHaveBeenCalled() }) it('should stop edge scrolling when pointer moves away from edge', () => { // Start edge scrolling near edge mockInputs.setCurrentScreenPoint(new Vec(5, 300)) edgeScrollManager.updateEdgeScrolling(300) expect(editor.setCamera).toHaveBeenCalled() // Move pointer to center - should stop scrolling editor.setCamera.mockClear() mockInputs.setCurrentScreenPoint(new Vec(500, 300)) edgeScrollManager.updateEdgeScrolling(16) expect(editor.setCamera).not.toHaveBeenCalled() }) it('should respect edge scroll delay', () => { mockInputs.setCurrentScreenPoint(new Vec(5, 300)) // First update - should not scroll yet due to delay edgeScrollManager.updateEdgeScrolling(100) expect(editor.setCamera).not.toHaveBeenCalled() // Second update - should trigger scrolling after delay edgeScrollManager.updateEdgeScrolling(150) expect(editor.setCamera).toHaveBeenCalled() }) }) describe('edge proximity detection', () => { it('should detect left edge proximity', () => { mockInputs.setCurrentScreenPoint(new Vec(5, 300)) edgeScrollManager.updateEdgeScrolling(300) // Enough to trigger after delay expect(editor.setCamera).toHaveBeenCalled() const callArgs = editor.setCamera.mock.calls[0][0] as Vec expect(callArgs.x).toBeGreaterThan(0) // Should scroll right when near left edge }) it('should detect right edge proximity', () => { mockInputs.setCurrentScreenPoint(new Vec(995, 300)) edgeScrollManager.updateEdgeScrolling(300) expect(editor.setCamera).toHaveBeenCalled() const callArgs = editor.setCamera.mock.calls[0][0] as Vec expect(callArgs.x).toBeLessThan(0) // Should scroll left when near right edge }) it('should detect top edge proximity', () => { mockInputs.setCurrentScreenPoint(new Vec(500, 5)) edgeScrollManager.updateEdgeScrolling(300) expect(editor.setCamera).toHaveBeenCalled() const callArgs = editor.setCamera.mock.calls[0][0] as Vec expect(callArgs.y).toBeGreaterThan(0) // Should scroll down when near top edge }) it('should detect bottom edge proximity', () => { mockInputs.setCurrentScreenPoint(new Vec(500, 595)) edgeScrollManager.updateEdgeScrolling(300) expect(editor.setCamera).toHaveBeenCalled() const callArgs = editor.setCamera.mock.calls[0][0] as Vec expect(callArgs.y).toBeLessThan(0) // Should scroll up when near bottom edge }) it('should handle corner proximity (both x and y)', () => { mockInputs.setCurrentScreenPoint(new Vec(5, 5)) edgeScrollManager.updateEdgeScrolling(300) expect(editor.setCamera).toHaveBeenCalled() const callArgs = editor.setCamera.mock.calls[0][0] as Vec expect(callArgs.x).toBeGreaterThan(0) // Should scroll right expect(callArgs.y).toBeGreaterThan(0) // Should scroll down }) }) describe('coarse pointer handling', () => { it('should account for coarse pointer width', () => { editor.getInstanceState.mockReturnValue({ ...editor.getInstanceState(), isCoarsePointer: true, insets: [false, false, false, false], }) mockInputs.setCurrentScreenPoint(new Vec(15, 300)) edgeScrollManager.updateEdgeScrolling(300) expect(editor.setCamera).toHaveBeenCalled() }) it('should not trigger edge scrolling for fine pointer at same position', () => { editor.getInstanceState.mockReturnValue({ ...editor.getInstanceState(), isCoarsePointer: false, insets: [false, false, false, false], }) mockInputs.setCurrentScreenPoint(new Vec(15, 300)) edgeScrollManager.updateEdgeScrolling(300) expect(editor.setCamera).not.toHaveBeenCalled() }) }) describe('camera movement calculation', () => { it('should calculate scroll speed based on user preference', () => { editor.user.getEdgeScrollSpeed.mockReturnValue(2) mockInputs.setCurrentScreenPoint(new Vec(5, 300)) edgeScrollManager.updateEdgeScrolling(300) expect(editor.setCamera).toHaveBeenCalled() const callArgs = editor.setCamera.mock.calls[0][0] as Vec expect(callArgs.x).toBeGreaterThan(0) // Should scroll when user speed is > 0 }) it('should apply screen size factor for small screens', () => { editor.getViewportScreenBounds.mockReturnValue(new Box(0, 0, 800, 600)) mockInputs.setCurrentScreenPoint(new Vec(5, 300)) edgeScrollManager.updateEdgeScrolling(300) expect(editor.setCamera).toHaveBeenCalled() }) it('should adjust scroll speed based on zoom level', () => { editor.getZoomLevel.mockReturnValue(2) mockInputs.setCurrentScreenPoint(new Vec(5, 300)) edgeScrollManager.updateEdgeScrolling(300) expect(editor.setCamera).toHaveBeenCalled() const callArgs = editor.setCamera.mock.calls[0][0] as Vec // Higher zoom should result in smaller camera movement expect(Math.abs(callArgs.x)).toBeLessThan(25) }) it('should add scroll delta to current camera position', () => { const currentCamera = new Vec(100, 200, 1) editor.getCamera.mockReturnValue(currentCamera) mockInputs.setCurrentScreenPoint(new Vec(5, 5)) edgeScrollManager.updateEdgeScrolling(300) expect(editor.setCamera).toHaveBeenCalled() const callArgs = editor.setCamera.mock.calls[0][0] as Vec expect(callArgs.x).toBeGreaterThan(100) // Should be added to current position expect(callArgs.y).toBeGreaterThan(200) // Should be added to current position expect(callArgs.z).toBe(1) // Z should remain unchanged }) }) describe('proximity factor calculation', () => { it('should return 0 when not near any edge', () => { mockInputs.setCurrentScreenPoint(new Vec(500, 300)) edgeScrollManager.updateEdgeScrolling(16) expect(editor.setCamera).not.toHaveBeenCalled() }) it('should cap proximity factor at 1', () => { mockInputs.setCurrentScreenPoint(new Vec(0, 300)) edgeScrollManager.updateEdgeScrolling(300) expect(editor.setCamera).toHaveBeenCalled() // The proximity factor should be capped, so movement shouldn't be infinite }) }) describe('edge cases and error handling', () => { it('should handle negative elapsed time', () => { mockInputs.setCurrentScreenPoint(new Vec(5, 300)) expect(() => edgeScrollManager.updateEdgeScrolling(-16)).not.toThrow() }) it('should handle very large elapsed time', () => { mockInputs.setCurrentScreenPoint(new Vec(5, 300)) expect(() => edgeScrollManager.updateEdgeScrolling(100000)).not.toThrow() }) it('should handle zero user edge scroll speed', () => { editor.user.getEdgeScrollSpeed.mockReturnValue(0) mockInputs.setCurrentScreenPoint(new Vec(5, 300)) edgeScrollManager.updateEdgeScrolling(300) if (editor.setCamera.mock.calls.length > 0) { const callArgs = editor.setCamera.mock.calls[0][0] as Vec expect(callArgs.x).toBe(0) expect(callArgs.y).toBe(0) } }) it('should handle extreme zoom levels', () => { mockInputs.setCurrentScreenPoint(new Vec(5, 300)) editor.getZoomLevel.mockReturnValue(0.01) // Very zoomed out expect(() => edgeScrollManager.updateEdgeScrolling(300)).not.toThrow() editor.getZoomLevel.mockReturnValue(100) // Very zoomed in expect(() => edgeScrollManager.updateEdgeScrolling(300)).not.toThrow() }) }) describe('state transitions', () => { it('should properly transition from not scrolling to scrolling', () => { // Start with no edge scrolling mockInputs.setCurrentScreenPoint(new Vec(500, 300)) edgeScrollManager.updateEdgeScrolling(16) expect(editor.setCamera).not.toHaveBeenCalled() // Move to edge - should start scrolling after delay mockInputs.setCurrentScreenPoint(new Vec(5, 300)) edgeScrollManager.updateEdgeScrolling(16) expect(editor.setCamera).not.toHaveBeenCalled() // Not yet, due to delay edgeScrollManager.updateEdgeScrolling(200) expect(editor.setCamera).toHaveBeenCalled() }) it('should accumulate edge scroll duration over multiple updates', () => { mockInputs.setCurrentScreenPoint(new Vec(5, 300)) // First update - not enough time edgeScrollManager.updateEdgeScrolling(50) expect(editor.setCamera).not.toHaveBeenCalled() // Second update - still not enough edgeScrollManager.updateEdgeScrolling(50) expect(editor.setCamera).not.toHaveBeenCalled() // Third update - now should trigger (50 + 50 + 101 = 201ms > 200ms delay) edgeScrollManager.updateEdgeScrolling(101) expect(editor.setCamera).toHaveBeenCalled() }) it('should reset duration when stopping edge scroll', () => { // Start edge scrolling mockInputs.setCurrentScreenPoint(new Vec(5, 300)) edgeScrollManager.updateEdgeScrolling(300) expect(editor.setCamera).toHaveBeenCalled() // Stop edge scrolling - move away editor.setCamera.mockClear() mockInputs.setCurrentScreenPoint(new Vec(500, 300)) edgeScrollManager.updateEdgeScrolling(16) expect(editor.setCamera).not.toHaveBeenCalled() }) }) })