/** * Unit tests for Web Editor V2 Selection Engine. * * These tests focus on deterministic scoring and selection behavior. * jsdom has no real layout engine, so we mock: * - document.elementsFromPoint / document.elementFromPoint * - element.getBoundingClientRect() * - window.getComputedStyle() */ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createSelectionEngine, type Modifiers, type SelectionCandidate, type SelectionEngine, } from '@/entrypoints/web-editor-v2/selection/selection-engine'; import type { RestoreFn, StyleOverrides } from './test-utils/dom'; import { createMockEvent, installDomMocks, mockBoundingClientRect, mockViewport, } 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. * In tests, elements with data-overlay="true" are considered overlay elements. */ function isOverlayElement(node: unknown): boolean { return node instanceof Element && node.getAttribute('data-overlay') === 'true'; } /** * Find a candidate by element in the candidates array. */ function getCandidate( candidates: SelectionCandidate[], element: Element, ): SelectionCandidate | undefined { return candidates.find((c) => c.element === element); } /** * Check if any of the candidate's reasons contain a specific substring. */ function hasReason(candidate: SelectionCandidate | undefined, substring: string): boolean { return candidate?.reasons.some((r) => r.includes(substring)) ?? false; } // ============================================================================= // Test Setup // ============================================================================= let restores: RestoreFn[] = []; let engine: SelectionEngine | null = null; beforeEach(() => { restores = []; document.body.innerHTML = ''; }); afterEach(() => { engine?.dispose(); engine = null; for (let i = restores.length - 1; i >= 0; i--) { restores[i]!(); } restores = []; }); // ============================================================================= // getCandidatesAtPoint Tests // ============================================================================= describe('selection-engine: getCandidatesAtPoint', () => { it('returns empty array when no elements are hit', () => { restores.push( installDomMocks({ elementsFromPoint: () => [], getComputedStyle: () => ({}), }), ); engine = createSelectionEngine({ isOverlayElement }); expect(engine.getCandidatesAtPoint(10, 10)).toEqual([]); }); it('skips overlay elements from hit testing', () => { const overlay = document.createElement('div'); overlay.setAttribute('data-overlay', 'true'); const button = document.createElement('button'); button.tabIndex = 0; document.body.append(overlay, button); restores.push(mockBoundingClientRect(overlay, { left: 0, top: 0, width: 200, height: 200 })); restores.push(mockBoundingClientRect(button, { left: 10, top: 10, width: 120, height: 48 })); const styleByEl = new Map([[button, { cursor: 'pointer' }]]); restores.push( installDomMocks({ elementsFromPoint: () => [overlay, button], getComputedStyle: (el) => styleByEl.get(el) ?? {}, }), ); engine = createSelectionEngine({ isOverlayElement }); const candidates = engine.getCandidatesAtPoint(12, 12); // Overlay should be excluded expect(candidates.some((c) => c.element === overlay)).toBe(false); // Button should be selected expect(candidates.some((c) => c.element === button)).toBe(true); }); it('scores interactive button element highly', () => { const wrapper = document.createElement('div'); const button = document.createElement('button'); button.tabIndex = 0; wrapper.append(button); document.body.append(wrapper); restores.push(mockBoundingClientRect(wrapper, { left: 0, top: 0, width: 200, height: 120 })); restores.push(mockBoundingClientRect(button, { left: 10, top: 10, width: 120, height: 48 })); const styleByEl = new Map([[button, { cursor: 'pointer' }]]); restores.push( installDomMocks({ elementsFromPoint: () => [button, wrapper], getComputedStyle: (el) => styleByEl.get(el) ?? {}, }), ); engine = createSelectionEngine({ isOverlayElement }); const candidates = engine.getCandidatesAtPoint(12, 12); // Button should have higher score due to interactive tag const buttonCandidate = getCandidate(candidates, button); expect(buttonCandidate).toBeDefined(); expect(hasReason(buttonCandidate, 'button') || hasReason(buttonCandidate, 'type')).toBe(true); }); it('prefers elements with visual boundaries', () => { const plain = document.createElement('div'); const bordered = document.createElement('div'); document.body.append(plain, bordered); restores.push(mockBoundingClientRect(plain, { left: 0, top: 0, width: 100, height: 100 })); restores.push(mockBoundingClientRect(bordered, { left: 0, top: 0, width: 100, height: 100 })); const styleByEl = new Map([ [ bordered, { borderTopWidth: '1px', borderRightWidth: '1px', borderBottomWidth: '1px', borderLeftWidth: '1px', borderTopStyle: 'solid', borderRightStyle: 'solid', borderBottomStyle: 'solid', borderLeftStyle: 'solid', }, ], ]); restores.push( installDomMocks({ elementsFromPoint: () => [plain, bordered], getComputedStyle: (el) => styleByEl.get(el) ?? {}, }), ); engine = createSelectionEngine({ isOverlayElement }); const candidates = engine.getCandidatesAtPoint(10, 10); const borderedCandidate = getCandidate(candidates, bordered); expect(borderedCandidate).toBeDefined(); expect(hasReason(borderedCandidate, 'border')).toBe(true); }); it('penalizes tiny elements', () => { const tiny = document.createElement('div'); const normal = document.createElement('div'); document.body.append(tiny, normal); restores.push(mockBoundingClientRect(tiny, { left: 0, top: 0, width: 2, height: 2 })); restores.push(mockBoundingClientRect(normal, { left: 0, top: 0, width: 100, height: 100 })); restores.push( installDomMocks({ elementsFromPoint: () => [tiny, normal], getComputedStyle: () => ({}), }), ); engine = createSelectionEngine({ isOverlayElement }); const candidates = engine.getCandidatesAtPoint(1, 1); const tinyCandidate = getCandidate(candidates, tiny); const normalCandidate = getCandidate(candidates, normal); expect(tinyCandidate).toBeDefined(); expect(normalCandidate).toBeDefined(); // Tiny element should have lower score expect((tinyCandidate?.score ?? 0) < (normalCandidate?.score ?? 0)).toBe(true); }); it('penalizes very large elements (viewport-sized)', () => { const huge = document.createElement('div'); const normal = document.createElement('div'); document.body.append(huge, normal); // Mock viewport as 800x600 restores.push(mockViewport(800, 600)); // Huge element takes 90% of viewport restores.push(mockBoundingClientRect(huge, { left: 0, top: 0, width: 720, height: 540 })); restores.push(mockBoundingClientRect(normal, { left: 10, top: 10, width: 100, height: 100 })); restores.push( installDomMocks({ elementsFromPoint: () => [normal, huge], getComputedStyle: () => ({}), }), ); engine = createSelectionEngine({ isOverlayElement }); const candidates = engine.getCandidatesAtPoint(50, 50); const hugeCandidate = getCandidate(candidates, huge); const normalCandidate = getCandidate(candidates, normal); expect(hugeCandidate).toBeDefined(); expect(normalCandidate).toBeDefined(); // Large element should have lower score due to size penalty expect((hugeCandidate?.score ?? 0) < (normalCandidate?.score ?? 0)).toBe(true); }); it('excludes invisible elements', () => { const hidden = document.createElement('div'); const visible = document.createElement('div'); document.body.append(hidden, visible); restores.push(mockBoundingClientRect(hidden, { left: 0, top: 0, width: 100, height: 100 })); restores.push(mockBoundingClientRect(visible, { left: 0, top: 0, width: 100, height: 100 })); const styleByEl = new Map([ [hidden, { display: 'none' }], [visible, { display: 'block' }], ]); restores.push( installDomMocks({ elementsFromPoint: () => [hidden, visible], getComputedStyle: (el) => styleByEl.get(el) ?? {}, }), ); engine = createSelectionEngine({ isOverlayElement }); const candidates = engine.getCandidatesAtPoint(50, 50); // Hidden element should be excluded expect(candidates.some((c) => c.element === hidden)).toBe(false); expect(candidates.some((c) => c.element === visible)).toBe(true); }); }); // ============================================================================= // findBestTarget Tests // ============================================================================= describe('selection-engine: findBestTarget', () => { it('returns null when no elements are hit', () => { restores.push( installDomMocks({ elementsFromPoint: () => [], getComputedStyle: () => ({}), }), ); engine = createSelectionEngine({ isOverlayElement }); expect(engine.findBestTarget(10, 10, NO_MODIFIERS)).toBeNull(); }); it('returns the best scored element', () => { const button = document.createElement('button'); button.tabIndex = 0; document.body.append(button); restores.push(mockBoundingClientRect(button, { left: 0, top: 0, width: 120, height: 48 })); restores.push( installDomMocks({ elementsFromPoint: () => [button], getComputedStyle: () => ({}), }), ); engine = createSelectionEngine({ isOverlayElement }); expect(engine.findBestTarget(10, 10, NO_MODIFIERS)).toBe(button); }); it('Alt modifier drills up to parent element', () => { const panel = document.createElement('section'); panel.id = 'panel'; const wrapper = document.createElement('div'); const button = document.createElement('button'); button.tabIndex = 0; wrapper.append(button); panel.append(wrapper); document.body.append(panel); restores.push(mockBoundingClientRect(panel, { left: 0, top: 0, width: 400, height: 300 })); restores.push(mockBoundingClientRect(wrapper, { left: 0, top: 0, width: 240, height: 160 })); restores.push(mockBoundingClientRect(button, { left: 10, top: 10, width: 120, height: 48 })); const styleByEl = new Map([ [panel, { paddingTop: '8px', paddingLeft: '8px' }], [button, { cursor: 'pointer' }], ]); restores.push( installDomMocks({ elementsFromPoint: () => [button, wrapper, panel], getComputedStyle: (el) => styleByEl.get(el) ?? {}, }), ); engine = createSelectionEngine({ isOverlayElement }); const target = engine.findBestTarget(12, 12, { ...NO_MODIFIERS, alt: true }); // Should drill up past wrapper to panel (which has visual boundary via padding) expect(target).toBe(panel); }); }); // ============================================================================= // findBestTargetFromEvent Tests // ============================================================================= describe('selection-engine: findBestTargetFromEvent', () => { it('Ctrl/Cmd selects the innermost visible element from composedPath', () => { const wrapper = document.createElement('div'); const button = document.createElement('button'); button.tabIndex = 0; const inner = document.createElement('span'); inner.textContent = 'Inner'; button.append(inner); wrapper.append(button); document.body.append(wrapper); restores.push(mockBoundingClientRect(wrapper, { left: 0, top: 0, width: 240, height: 160 })); restores.push(mockBoundingClientRect(button, { left: 10, top: 10, width: 120, height: 48 })); restores.push(mockBoundingClientRect(inner, { left: 14, top: 14, width: 50, height: 20 })); const styleByEl = new Map([[button, { cursor: 'pointer' }]]); restores.push( installDomMocks({ elementsFromPoint: () => [inner, button, wrapper], getComputedStyle: (el) => styleByEl.get(el) ?? {}, }), ); engine = createSelectionEngine({ isOverlayElement }); const event = createMockEvent({ clientX: 16, clientY: 16, path: [inner, button, wrapper, document.body, document], }); const target = engine.findBestTargetFromEvent(event, { ...NO_MODIFIERS, ctrl: true }); // Ctrl should select innermost visible element expect(target).toBe(inner); }); it('Alt in event-based selection drills up from best target', () => { const panel = document.createElement('section'); panel.id = 'panel'; const wrapper = document.createElement('div'); const button = document.createElement('button'); button.tabIndex = 0; wrapper.append(button); panel.append(wrapper); document.body.append(panel); restores.push(mockBoundingClientRect(panel, { left: 0, top: 0, width: 400, height: 300 })); restores.push(mockBoundingClientRect(wrapper, { left: 0, top: 0, width: 240, height: 160 })); restores.push(mockBoundingClientRect(button, { left: 10, top: 10, width: 120, height: 48 })); const styleByEl = new Map([ [panel, { paddingTop: '8px', paddingLeft: '8px' }], [button, { cursor: 'pointer' }], ]); restores.push( installDomMocks({ elementsFromPoint: () => [button, wrapper, panel], getComputedStyle: (el) => styleByEl.get(el) ?? {}, }), ); engine = createSelectionEngine({ isOverlayElement }); const event = createMockEvent({ clientX: 12, clientY: 12, path: [button, wrapper, panel, document.body, document], }); const target = engine.findBestTargetFromEvent(event, { ...NO_MODIFIERS, alt: true }); expect(target).toBe(panel); }); }); // ============================================================================= // getParentCandidate Tests // ============================================================================= describe('selection-engine: getParentCandidate', () => { it('returns null for body element', () => { engine = createSelectionEngine({ isOverlayElement }); expect(engine.getParentCandidate(document.body)).toBeNull(); }); it('returns first non-wrapper ancestor', () => { const section = document.createElement('section'); section.id = 'section'; const wrapper = document.createElement('div'); const button = document.createElement('button'); wrapper.append(button); section.append(wrapper); document.body.append(section); restores.push(mockBoundingClientRect(section, { left: 0, top: 0, width: 400, height: 300 })); restores.push(mockBoundingClientRect(wrapper, { left: 0, top: 0, width: 240, height: 160 })); restores.push(mockBoundingClientRect(button, { left: 10, top: 10, width: 120, height: 48 })); // Section has visual boundary const styleByEl = new Map([ [section, { paddingTop: '8px', paddingLeft: '8px' }], ]); restores.push( installDomMocks({ elementsFromPoint: () => [], getComputedStyle: (el) => styleByEl.get(el) ?? {}, }), ); engine = createSelectionEngine({ isOverlayElement }); const parent = engine.getParentCandidate(button); // Should skip wrapper and return section expect(parent).toBe(section); }); }); // ============================================================================= // dispose Tests // ============================================================================= describe('selection-engine: dispose', () => { it('can be called multiple times safely', () => { engine = createSelectionEngine({ isOverlayElement }); expect(() => { engine!.dispose(); engine!.dispose(); }).not.toThrow(); }); });