/** * Selection Manager Tests * * Tests for text selection functionality including: * - Basic selection operations * - Absolute coordinate system for scroll persistence * - Selection clearing behavior * - Auto-scroll during drag selection * - Copy functionality with scrollback * * Test Isolation Pattern: * Uses createIsolatedTerminal() to ensure each test gets its own WASM instance. */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import type { Terminal } from './terminal'; import { createIsolatedTerminal } from './test-helpers'; /** * Helper to set selection using absolute coordinates */ function setSelectionAbsolute( term: Terminal, startCol: number, startAbsRow: number, endCol: number, endAbsRow: number ): void { const selMgr = (term as any).selectionManager; if (selMgr) { (selMgr as any).selectionStart = { col: startCol, absoluteRow: startAbsRow }; (selMgr as any).selectionEnd = { col: endCol, absoluteRow: endAbsRow }; } } /** * Helper to convert viewport row to absolute row */ function viewportToAbsolute(term: Terminal, viewportRow: number): number { const scrollbackLength = term.wasmTerm?.getScrollbackLength() ?? 0; const viewportY = term.getViewportY(); return scrollbackLength + viewportRow - Math.floor(viewportY); } describe('SelectionManager', () => { let container: HTMLElement | null = null; beforeEach(async () => { if (typeof document !== 'undefined') { container = document.createElement('div'); document.body.appendChild(container); } }); afterEach(() => { if (container && container.parentNode) { container.parentNode.removeChild(container); container = null; } }); describe('Construction', () => { test('creates without errors', async () => { const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); expect(term).toBeDefined(); }); }); describe('API', () => { test('has required public methods', async () => { if (!container) return; const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container); const selMgr = (term as any).selectionManager; expect(typeof selMgr.getSelection).toBe('function'); expect(typeof selMgr.hasSelection).toBe('function'); expect(typeof selMgr.clearSelection).toBe('function'); expect(typeof selMgr.selectAll).toBe('function'); expect(typeof selMgr.getSelectionCoords).toBe('function'); expect(typeof selMgr.dispose).toBe('function'); expect(typeof selMgr.getDirtySelectionRows).toBe('function'); expect(typeof selMgr.clearDirtySelectionRows).toBe('function'); term.dispose(); }); }); describe('Selection with absolute coordinates', () => { test('hasSelection returns false when no selection', async () => { if (!container) return; const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container); const selMgr = (term as any).selectionManager; expect(selMgr.hasSelection()).toBe(false); term.dispose(); }); test('hasSelection returns true when selection exists', async () => { if (!container) return; const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container); term.write('Hello World\r\n'); // Set selection using absolute coordinates setSelectionAbsolute(term, 0, 0, 5, 0); const selMgr = (term as any).selectionManager; expect(selMgr.hasSelection()).toBe(true); term.dispose(); }); test('hasSelection returns false for single cell selection', async () => { if (!container) return; const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container); // Same start and end = no real selection setSelectionAbsolute(term, 5, 0, 5, 0); const selMgr = (term as any).selectionManager; expect(selMgr.hasSelection()).toBe(false); term.dispose(); }); test('clearSelection clears selection and marks rows dirty', async () => { if (!container) return; const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container); term.write('Line 1\r\nLine 2\r\nLine 3\r\n'); const scrollbackLen = term.wasmTerm!.getScrollbackLength(); setSelectionAbsolute(term, 0, scrollbackLen, 5, scrollbackLen + 2); const selMgr = (term as any).selectionManager; expect(selMgr.hasSelection()).toBe(true); selMgr.clearSelection(); expect(selMgr.hasSelection()).toBe(false); // Dirty rows should be marked for redraw const dirtyRows = selMgr.getDirtySelectionRows(); expect(dirtyRows.size).toBeGreaterThan(0); term.dispose(); }); }); describe('Selection text extraction', () => { test('getSelection returns empty string when no selection', async () => { if (!container) return; const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container); const selMgr = (term as any).selectionManager; expect(selMgr.getSelection()).toBe(''); term.dispose(); }); test('getSelection extracts text from screen buffer', async () => { if (!container) return; const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container); term.write('Hello World\r\n'); const scrollbackLen = term.wasmTerm!.getScrollbackLength(); // Select "Hello" (first 5 characters) setSelectionAbsolute(term, 0, scrollbackLen, 4, scrollbackLen); const selMgr = (term as any).selectionManager; expect(selMgr.getSelection()).toBe('Hello'); term.dispose(); }); test('getSelection extracts multi-line text', async () => { if (!container) return; const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container); term.write('Line 1\r\nLine 2\r\nLine 3\r\n'); const scrollbackLen = term.wasmTerm!.getScrollbackLength(); // Select all three lines setSelectionAbsolute(term, 0, scrollbackLen, 5, scrollbackLen + 2); const selMgr = (term as any).selectionManager; const text = selMgr.getSelection(); expect(text).toContain('Line 1'); expect(text).toContain('Line 2'); expect(text).toContain('Line 3'); term.dispose(); }); test('getSelection extracts text from scrollback', async () => { if (!container) return; const term = await createIsolatedTerminal({ cols: 80, rows: 24, scrollback: 1000 }); term.open(container); // Write enough lines to create scrollback for (let i = 0; i < 50; i++) { term.write(`Line ${i.toString().padStart(3, '0')}\r\n`); } const scrollbackLen = term.wasmTerm!.getScrollbackLength(); expect(scrollbackLen).toBeGreaterThan(0); // Select from scrollback (first few lines) setSelectionAbsolute(term, 0, 0, 10, 2); const selMgr = (term as any).selectionManager; const text = selMgr.getSelection(); expect(text).toContain('Line 000'); expect(text).toContain('Line 001'); expect(text).toContain('Line 002'); term.dispose(); }); test('getSelection extracts text spanning scrollback and screen', async () => { if (!container) return; const term = await createIsolatedTerminal({ cols: 80, rows: 24, scrollback: 1000 }); term.open(container); // Write enough lines to fill scrollback and screen for (let i = 0; i < 50; i++) { term.write(`Line ${i.toString().padStart(3, '0')}\r\n`); } const scrollbackLen = term.wasmTerm!.getScrollbackLength(); // Select spanning scrollback and screen // End of scrollback through beginning of screen setSelectionAbsolute(term, 0, scrollbackLen - 2, 10, scrollbackLen + 2); const selMgr = (term as any).selectionManager; const text = selMgr.getSelection(); // Should contain lines from both regions expect(text.split('\n').length).toBeGreaterThanOrEqual(4); term.dispose(); }); }); describe('Selection persistence during scroll', () => { test('selection coordinates are preserved when scrolling', async () => { if (!container) return; const term = await createIsolatedTerminal({ cols: 80, rows: 24, scrollback: 1000 }); term.open(container); // Write content for (let i = 0; i < 50; i++) { term.write(`Line ${i.toString().padStart(3, '0')}\r\n`); } const scrollbackLen = term.wasmTerm!.getScrollbackLength(); // Set selection at specific absolute position const startAbsRow = scrollbackLen + 5; const endAbsRow = scrollbackLen + 10; setSelectionAbsolute(term, 0, startAbsRow, 10, endAbsRow); const selMgr = (term as any).selectionManager; const textBefore = selMgr.getSelection(); // Scroll up term.scrollLines(-10); // Selection should still return the same text const textAfter = selMgr.getSelection(); expect(textAfter).toBe(textBefore); term.dispose(); }); test('selection coords convert correctly after scrolling', async () => { if (!container) return; const term = await createIsolatedTerminal({ cols: 80, rows: 24, scrollback: 1000 }); term.open(container); // Write content for (let i = 0; i < 50; i++) { term.write(`Line ${i.toString().padStart(3, '0')}\r\n`); } const scrollbackLen = term.wasmTerm!.getScrollbackLength(); // Set selection in screen buffer area setSelectionAbsolute(term, 0, scrollbackLen, 10, scrollbackLen + 5); const selMgr = (term as any).selectionManager; // Get viewport coords before scroll const coordsBefore = selMgr.getSelectionCoords(); expect(coordsBefore).not.toBeNull(); // Scroll up 10 lines term.scrollLines(-10); // Get viewport coords after scroll - they should have shifted const coordsAfter = selMgr.getSelectionCoords(); expect(coordsAfter).not.toBeNull(); // Viewport row should have increased by the scroll amount expect(coordsAfter!.startRow).toBe(coordsBefore!.startRow + 10); expect(coordsAfter!.endRow).toBe(coordsBefore!.endRow + 10); term.dispose(); }); test('selection outside viewport returns null coords but preserves text', async () => { if (!container) return; const term = await createIsolatedTerminal({ cols: 80, rows: 24, scrollback: 1000 }); term.open(container); // Write content for (let i = 0; i < 100; i++) { term.write(`Line ${i.toString().padStart(3, '0')}\r\n`); } // Select near the bottom of the buffer const scrollbackLen = term.wasmTerm!.getScrollbackLength(); setSelectionAbsolute(term, 0, scrollbackLen + 10, 10, scrollbackLen + 15); const selMgr = (term as any).selectionManager; const text = selMgr.getSelection(); // Scroll to top - selection should be way off screen term.scrollToTop(); // Coords should be null (off screen) but text should still work const coords = selMgr.getSelectionCoords(); expect(coords).toBeNull(); // Text extraction should still work expect(selMgr.getSelection()).toBe(text); term.dispose(); }); }); describe('Dirty row tracking', () => { test('getDirtySelectionRows returns empty set initially', async () => { if (!container) return; const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container); const selMgr = (term as any).selectionManager; expect(selMgr.getDirtySelectionRows().size).toBe(0); term.dispose(); }); test('clearSelection marks selection rows as dirty', async () => { if (!container) return; const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container); term.write('Test content\r\n'); const scrollbackLen = term.wasmTerm!.getScrollbackLength(); setSelectionAbsolute(term, 0, scrollbackLen, 5, scrollbackLen + 3); const selMgr = (term as any).selectionManager; selMgr.clearSelection(); const dirtyRows = selMgr.getDirtySelectionRows(); expect(dirtyRows.size).toBeGreaterThan(0); term.dispose(); }); test('clearDirtySelectionRows clears the set', async () => { if (!container) return; const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container); term.write('Test\r\n'); const scrollbackLen = term.wasmTerm!.getScrollbackLength(); setSelectionAbsolute(term, 0, scrollbackLen, 5, scrollbackLen); const selMgr = (term as any).selectionManager; selMgr.clearSelection(); expect(selMgr.getDirtySelectionRows().size).toBeGreaterThan(0); selMgr.clearDirtySelectionRows(); expect(selMgr.getDirtySelectionRows().size).toBe(0); term.dispose(); }); }); describe('Backward selection', () => { test('handles selection from right to left', async () => { if (!container) return; const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container); term.write('Hello World\r\n'); const scrollbackLen = term.wasmTerm!.getScrollbackLength(); // Select backwards (end before start) setSelectionAbsolute(term, 10, scrollbackLen, 0, scrollbackLen); const selMgr = (term as any).selectionManager; const text = selMgr.getSelection(); expect(text).toBe('Hello World'); term.dispose(); }); test('handles selection from bottom to top', async () => { if (!container) return; const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container); term.write('Line 1\r\nLine 2\r\nLine 3\r\n'); const scrollbackLen = term.wasmTerm!.getScrollbackLength(); // Select backwards (end row before start row) setSelectionAbsolute(term, 5, scrollbackLen + 2, 0, scrollbackLen); const selMgr = (term as any).selectionManager; const text = selMgr.getSelection(); expect(text).toContain('Line 1'); expect(text).toContain('Line 2'); expect(text).toContain('Line 3'); term.dispose(); }); }); describe('selectAll', () => { test('selectAll selects entire viewport', async () => { if (!container) return; const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container); term.write('Hello\r\nWorld\r\n'); const selMgr = (term as any).selectionManager; selMgr.selectAll(); expect(selMgr.hasSelection()).toBe(true); const coords = selMgr.getSelectionCoords(); expect(coords).not.toBeNull(); expect(coords!.startRow).toBe(0); expect(coords!.startCol).toBe(0); expect(coords!.endRow).toBe(23); // rows - 1 term.dispose(); }); }); describe('select() API', () => { test('select() creates selection at specified position', async () => { if (!container) return; const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container); term.write('Hello World\r\n'); const selMgr = (term as any).selectionManager; selMgr.select(0, 0, 5); expect(selMgr.hasSelection()).toBe(true); expect(selMgr.getSelection()).toBe('Hello'); term.dispose(); }); }); describe('selectLines() API', () => { test('selectLines() selects entire lines', async () => { if (!container) return; const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container); term.write('Line 1\r\nLine 2\r\nLine 3\r\n'); const selMgr = (term as any).selectionManager; selMgr.selectLines(0, 1); expect(selMgr.hasSelection()).toBe(true); const text = selMgr.getSelection(); expect(text).toContain('Line 1'); expect(text).toContain('Line 2'); term.dispose(); }); }); });