diff --git a/addons/addon-search/src/SearchEngine.test.ts b/addons/addon-search/src/SearchEngine.test.ts index b0054bd94..2c5cc9543 100644 --- a/addons/addon-search/src/SearchEngine.test.ts +++ b/addons/addon-search/src/SearchEngine.test.ts @@ -256,6 +256,12 @@ describe('SearchEngine', () => { assert.strictEqual(searchEngine.find('.*?', 0, 0, { regex: true }), undefined); // Empty matches should be ignored }); + + it('should ignore empty regex matches when searching backwards', async () => { + await writeP(terminal, 'Hello World'); + + assert.strictEqual(searchEngine.findPreviousWithSelection('.*?', { regex: true }), undefined); + }); }); describe('combined options', () => { diff --git a/addons/addon-search/src/SearchLineCache.test.ts b/addons/addon-search/src/SearchLineCache.test.ts index 77f64c81e..c8a39f49b 100644 --- a/addons/addon-search/src/SearchLineCache.test.ts +++ b/addons/addon-search/src/SearchLineCache.test.ts @@ -244,6 +244,17 @@ describe('SearchLineCache', () => { assert.equal(retrieved![1].length, 4); }); + it('should lazily cache lowercased line text via SearchEngine usage', async () => { + await writeP(terminal, 'Hello WORLD'); + cache.initLinesCache(); + const entry = cache.translateBufferLineToStringWithWrap(0, true); + cache.setLineInCache(0, entry); + assert.equal(entry[2], undefined); + entry[2] = entry[0].toLowerCase(); + cache.setLineInCache(0, entry); + assert.strictEqual(cache.getLineFromCache(0)?.[2], 'hello world'); + }); + it('should handle unicode characters in cache entries', () => { const entry: LineCacheEntry = [ 'Hello δΈ–η•Œ 🌍 ζ΅‹θ―•', diff --git a/addons/addon-search/src/SearchResultTracker.test.ts b/addons/addon-search/src/SearchResultTracker.test.ts new file mode 100644 index 000000000..8f9d1822c --- /dev/null +++ b/addons/addon-search/src/SearchResultTracker.test.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ +import { assert } from 'chai'; +import { SearchResultTracker } from './SearchResultTracker'; +import type { ISearchResult } from './SearchEngine'; + +describe('SearchResultTracker', () => { + let tracker: SearchResultTracker; + + beforeEach(() => { + tracker = new SearchResultTracker(); + }); + + afterEach(() => { + tracker.dispose(); + }); + + describe('updateResults', () => { + it('should store results without copying when within limit', () => { + const results: ISearchResult[] = [{ term: 'a', row: 0, col: 0, size: 1 }]; + tracker.updateResults(results, 10); + assert.strictEqual(tracker.searchResults, results); + }); + }); + + describe('findResultIndex', () => { + it('should return the index of a tracked result', () => { + const results: ISearchResult[] = [ + { term: 'a', row: 0, col: 0, size: 1 }, + { term: 'b', row: 0, col: 2, size: 1 } + ]; + tracker.updateResults(results, 10); + assert.strictEqual(tracker.findResultIndex(results[1]), 1); + }); + + it('should return -1 when the result is not tracked', () => { + tracker.updateResults([{ term: 'a', row: 0, col: 0, size: 1 }], 10); + assert.strictEqual(tracker.findResultIndex({ term: 'z', row: 1, col: 0, size: 1 }), -1); + }); + }); + + describe('fireResultsChanged', () => { + it('should not fire when decorations are disabled', () => { + let fired = false; + tracker.onDidChangeResults(() => fired = true); + tracker.updateResults([{ term: 'a', row: 0, col: 0, size: 1 }], 10); + tracker.fireResultsChanged(false); + assert.strictEqual(fired, false); + }); + }); +}); diff --git a/addons/addon-search/src/SearchState.test.ts b/addons/addon-search/src/SearchState.test.ts new file mode 100644 index 000000000..974c4b491 --- /dev/null +++ b/addons/addon-search/src/SearchState.test.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ +import { assert } from 'chai'; +import { SearchState } from './SearchState'; +import type { ISearchOptions } from '@xterm/addon-search'; + +const DECORATIONS_A: ISearchOptions = { + decorations: { + activeMatchColorOverviewRuler: '#ff0000', + matchOverviewRuler: '#00ffff' + } +}; + +const DECORATIONS_B: ISearchOptions = { + decorations: { + activeMatchColorOverviewRuler: '#00ff00', + matchOverviewRuler: '#00ffff' + } +}; + +describe('SearchState', () => { + let state: SearchState; + + beforeEach(() => { + state = new SearchState(); + }); + + describe('didOptionsChange', () => { + it('should detect caseSensitive changes', () => { + state.lastSearchOptions = { caseSensitive: false }; + assert.strictEqual(state.didOptionsChange({ caseSensitive: true }), true); + }); + + it('should detect decoration option changes', () => { + state.lastSearchOptions = DECORATIONS_A; + assert.strictEqual(state.didOptionsChange(DECORATIONS_B), true); + }); + + it('should return false when semantic options are unchanged', () => { + const options: ISearchOptions = { caseSensitive: false, regex: false, wholeWord: false }; + state.lastSearchOptions = options; + assert.strictEqual(state.didOptionsChange({ caseSensitive: false, regex: false, wholeWord: false }), false); + }); + }); + + describe('shouldUpdateHighlighting', () => { + it('should update when options change for the same term', () => { + state.cachedSearchTerm = 'test'; + state.lastSearchOptions = { caseSensitive: false, decorations: DECORATIONS_A.decorations }; + assert.strictEqual(state.shouldUpdateHighlighting('test', { caseSensitive: true, decorations: DECORATIONS_A.decorations }), true); + }); + + it('should not update without decorations', () => { + state.cachedSearchTerm = 'test'; + state.lastSearchOptions = { caseSensitive: false }; + assert.strictEqual(state.shouldUpdateHighlighting('test', { caseSensitive: true }), false); + }); + }); +});