/** * Unit tests for Web Editor V2 Event Controller. * * These tests focus on the selecting mode behavior: * - Clicking within selection subtree prepares drag candidate * - Clicking outside selection triggers reselection (Bug 1 fix) */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createEventController, type EventController, type EventControllerOptions, type Modifiers, } from '@/entrypoints/web-editor-v2/core/event-controller'; import type { RestoreFn } from './test-utils/dom'; import { mockBoundingClientRect } from './test-utils/dom'; // ============================================================================= // Test Utilities // ============================================================================= const NO_MODIFIERS: Modifiers = { alt: false, shift: false, ctrl: false, meta: false }; /** * Check if an element is part of the editor overlay. */ function isOverlayElement(node: unknown): boolean { return node instanceof Element && node.getAttribute('data-overlay') === 'true'; } /** * Create a minimal mock PointerEvent for testing. * jsdom doesn't support PointerEvent, so we create a MouseEvent and extend it. */ function createPointerEvent( type: string, options: { clientX?: number; clientY?: number; button?: number; pointerId?: number; target?: EventTarget | null; } = {}, ): MouseEvent & { pointerId: number } { const event = new MouseEvent(type, { bubbles: true, cancelable: true, clientX: options.clientX ?? 0, clientY: options.clientY ?? 0, button: options.button ?? 0, }); // Add pointerId property (jsdom doesn't have PointerEvent) Object.defineProperty(event, 'pointerId', { value: options.pointerId ?? 1, writable: false, }); // Mock composedPath to return target path if (options.target) { vi.spyOn(event, 'composedPath').mockReturnValue([options.target as EventTarget]); } return event as MouseEvent & { pointerId: number }; } // ============================================================================= // Test Setup // ============================================================================= let restores: RestoreFn[] = []; let controller: EventController | null = null; beforeEach(() => { restores = []; document.body.innerHTML = ''; }); afterEach(() => { controller?.dispose(); controller = null; for (let i = restores.length - 1; i >= 0; i--) { restores[i]!(); } restores = []; vi.restoreAllMocks(); }); // ============================================================================= // Selecting Mode Tests (Bug 1 Fix) // ============================================================================= describe('event-controller: selecting mode click behavior', () => { it('clicking within selection subtree prepares drag candidate (does not trigger onSelect)', () => { // Setup DOM const selected = document.createElement('div'); selected.id = 'selected'; const child = document.createElement('span'); child.id = 'child'; selected.appendChild(child); document.body.appendChild(selected); // Mock rect for selected element restores.push(mockBoundingClientRect(selected, { left: 0, top: 0, width: 100, height: 100 })); restores.push(mockBoundingClientRect(child, { left: 10, top: 10, width: 50, height: 50 })); // Setup callbacks const onSelect = vi.fn(); const onStartDrag = vi.fn().mockReturnValue(true); const options: EventControllerOptions = { isOverlayElement, isEditorUiElement: () => false, getSelectedElement: () => selected, getEditingElement: () => null, findTargetForSelect: () => child, onHover: vi.fn(), onSelect, onDeselect: vi.fn(), onStartDrag, }; controller = createEventController(options); controller.setMode('selecting'); // Simulate pointerdown within selected element const event = createPointerEvent('pointerdown', { clientX: 20, clientY: 20, target: child, }); document.dispatchEvent(event); // onSelect should NOT be called (we're preparing drag instead) expect(onSelect).not.toHaveBeenCalled(); }); it('clicking outside selection triggers reselection (Bug 1 fix)', () => { // Setup DOM const selected = document.createElement('div'); selected.id = 'selected'; document.body.appendChild(selected); const other = document.createElement('div'); other.id = 'other'; document.body.appendChild(other); // Mock rects restores.push(mockBoundingClientRect(selected, { left: 0, top: 0, width: 100, height: 100 })); restores.push(mockBoundingClientRect(other, { left: 200, top: 0, width: 100, height: 100 })); // Setup callbacks const onSelect = vi.fn(); const onStartDrag = vi.fn().mockReturnValue(true); const options: EventControllerOptions = { isOverlayElement, isEditorUiElement: () => false, getSelectedElement: () => selected, getEditingElement: () => null, findTargetForSelect: () => other, // Returns the "other" element as target onHover: vi.fn(), onSelect, onDeselect: vi.fn(), onStartDrag, }; controller = createEventController(options); controller.setMode('selecting'); // Simulate mousedown outside selected element (on "other") // Use mousedown since jsdom doesn't support PointerEvent const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true, clientX: 250, // Outside selected (0-100), inside other (200-300) clientY: 50, button: 0, }); // Mock composedPath to return a path that does NOT include "selected" // This simulates clicking outside the selection vi.spyOn(event, 'composedPath').mockReturnValue([other, document.body, document]); document.dispatchEvent(event); // onSelect SHOULD be called with the new element expect(onSelect).toHaveBeenCalledWith(other, expect.any(Object)); }); it('clicking outside with no valid target does not trigger onSelect', () => { // Setup DOM const selected = document.createElement('div'); selected.id = 'selected'; document.body.appendChild(selected); // Mock rect restores.push(mockBoundingClientRect(selected, { left: 0, top: 0, width: 100, height: 100 })); // Setup callbacks const onSelect = vi.fn(); const options: EventControllerOptions = { isOverlayElement, isEditorUiElement: () => false, getSelectedElement: () => selected, getEditingElement: () => null, findTargetForSelect: () => null, // No valid target found onHover: vi.fn(), onSelect, onDeselect: vi.fn(), onStartDrag: vi.fn(), }; controller = createEventController(options); controller.setMode('selecting'); // Simulate pointerdown outside selected element const event = createPointerEvent('pointerdown', { clientX: 500, clientY: 500, target: document.body, }); document.dispatchEvent(event); // onSelect should NOT be called (no valid target) expect(onSelect).not.toHaveBeenCalled(); }); });