diff --git a/code/websites/pokedex.online/tests/unit/composables/useGamemasterSearch.test.js b/code/websites/pokedex.online/tests/unit/composables/useGamemasterSearch.test.js new file mode 100644 index 0000000..0475c98 --- /dev/null +++ b/code/websites/pokedex.online/tests/unit/composables/useGamemasterSearch.test.js @@ -0,0 +1,354 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { ref } from 'vue'; +import { useGamemasterSearch } from '@/composables/useGamemasterSearch.js'; + +describe('useGamemasterSearch', () => { + let fileLines; + let displayLines; + let composable; + + beforeEach(() => { + // Mock Web Worker + global.Worker = vi.fn(() => ({ + postMessage: vi.fn(), + onmessage: null, + onerror: null, + terminate: vi.fn() + })); + + // Sample file content + fileLines = ref([ + 'const hello = "world";', + 'console.log("hello");', + 'function test() {', + ' return "hello world";', + '}', + 'const greeting = "hello";' + ]); + + displayLines = ref( + fileLines.value.map((content, index) => ({ + lineNumber: index + 1, + content, + hasMatch: false + })) + ); + + composable = useGamemasterSearch(fileLines, displayLines); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('initialization', () => { + it('should initialize with empty search state', () => { + expect(composable.searchQuery.value).toBe(''); + expect(composable.searchResults.value).toEqual([]); + expect(composable.currentResultIndex.value).toBe(0); + expect(composable.isSearching.value).toBe(false); + expect(composable.searchError.value).toBeNull(); + }); + + it('should have search history', () => { + expect(composable.searchHistory).toBeDefined(); + }); + }); + + describe('clearSearch', () => { + it('should clear search query and results', () => { + composable.searchQuery.value = 'test'; + composable.searchResults.value = [0, 2, 5]; + composable.currentResultIndex.value = 1; + + composable.clearSearch(); + + expect(composable.searchQuery.value).toBe(''); + expect(composable.searchResults.value).toEqual([]); + expect(composable.currentResultIndex.value).toBe(0); + }); + + it('should clear hasMatch from display lines', () => { + displayLines.value[0].hasMatch = true; + displayLines.value[2].hasMatch = true; + + composable.clearSearch(); + + expect(displayLines.value.every(line => !line.hasMatch)).toBe(true); + }); + }); + + describe('clearSearchResults', () => { + it('should clear results but keep query', () => { + composable.searchQuery.value = 'hello'; + composable.searchResults.value = [0, 1, 5]; + + composable.clearSearchResults(); + + expect(composable.searchQuery.value).toBe('hello'); + expect(composable.searchResults.value).toEqual([]); + }); + + it('should clear error state', () => { + composable.searchError.value = 'Some error'; + + composable.clearSearchResults(); + + expect(composable.searchError.value).toBeNull(); + }); + }); + + describe('goToNextResult', () => { + beforeEach(() => { + composable.searchResults.value = [0, 2, 4]; + composable.currentResultIndex.value = 0; + }); + + it('should move to next result', () => { + composable.goToNextResult(); + expect(composable.currentResultIndex.value).toBe(1); + + composable.goToNextResult(); + expect(composable.currentResultIndex.value).toBe(2); + }); + + it('should wrap around to start', () => { + composable.currentResultIndex.value = 2; + composable.goToNextResult(); + expect(composable.currentResultIndex.value).toBe(0); + }); + + it('should do nothing when no results', () => { + composable.searchResults.value = []; + composable.currentResultIndex.value = 0; + composable.goToNextResult(); + expect(composable.currentResultIndex.value).toBe(0); + }); + }); + + describe('goToPrevResult', () => { + beforeEach(() => { + composable.searchResults.value = [0, 2, 4]; + composable.currentResultIndex.value = 2; + }); + + it('should move to previous result', () => { + composable.goToPrevResult(); + expect(composable.currentResultIndex.value).toBe(1); + + composable.goToPrevResult(); + expect(composable.currentResultIndex.value).toBe(0); + }); + + it('should wrap around to end', () => { + composable.currentResultIndex.value = 0; + composable.goToPrevResult(); + expect(composable.currentResultIndex.value).toBe(2); + }); + + it('should do nothing when no results', () => { + composable.searchResults.value = []; + composable.currentResultIndex.value = 0; + composable.goToPrevResult(); + expect(composable.currentResultIndex.value).toBe(0); + }); + }); + + describe('getHighlightedContent', () => { + it('should return content unchanged when no search query', () => { + composable.searchQuery.value = ''; + const content = 'hello world'; + expect(composable.getHighlightedContent(content)).toBe(content); + }); + + it('should highlight matching text', () => { + composable.searchQuery.value = 'hello'; + const content = 'hello world'; + const result = composable.getHighlightedContent(content); + expect(result).toContain('hello'); + }); + + it('should be case-insensitive', () => { + composable.searchQuery.value = 'HELLO'; + const content = 'hello world'; + const result = composable.getHighlightedContent(content); + expect(result).toContain('hello'); + }); + + it('should highlight multiple occurrences', () => { + composable.searchQuery.value = 'o'; + const content = 'hello world'; + const result = composable.getHighlightedContent(content); + const matches = (result.match(/o<\/mark>/g) || []).length; + expect(matches).toBe(2); + }); + + it('should escape regex special characters', () => { + composable.searchQuery.value = '.*'; + const content = 'test .* pattern'; + expect(() => composable.getHighlightedContent(content)).not.toThrow(); + }); + }); + + describe('computed properties', () => { + describe('currentResultLineNumber', () => { + it('should return null when no results', () => { + composable.searchResults.value = []; + expect(composable.currentResultLineNumber.value).toBeNull(); + }); + + it('should return current result as 1-based line number', () => { + composable.searchResults.value = [0, 2, 4]; + composable.currentResultIndex.value = 1; + expect(composable.currentResultLineNumber.value).toBe(3); // Line 3 (index 2 + 1) + }); + }); + + describe('resultCountDisplay', () => { + it('should show "0 results" when empty', () => { + composable.searchResults.value = []; + expect(composable.resultCountDisplay.value).toBe('0 results'); + }); + + it('should show current/total format', () => { + composable.searchResults.value = [0, 2, 4]; + composable.currentResultIndex.value = 1; + expect(composable.resultCountDisplay.value).toBe('2 / 3'); + }); + }); + + describe('hasSearchResults', () => { + it('should be false when no results', () => { + composable.searchResults.value = []; + expect(composable.hasSearchResults.value).toBe(false); + }); + + it('should be true when results exist', () => { + composable.searchResults.value = [0, 1, 2]; + expect(composable.hasSearchResults.value).toBe(true); + }); + }); + }); + + describe('updateFilters', () => { + it('should be callable without errors', () => { + expect(() => composable.updateFilters()).not.toThrow(); + }); + + it('should accept filter options', () => { + expect(() => { + composable.updateFilters({ + fuzzy: true, + wholeWord: true, + caseSensitive: false + }); + }).not.toThrow(); + }); + }); + + describe('applyHistoryItem', () => { + it('should set search query to history item', async () => { + const historyItem = 'previous search'; + composable.applyHistoryItem(historyItem); + expect(composable.searchQuery.value).toBe(historyItem); + }); + }); + + describe('executeSearch', () => { + it('should set search query and trigger search', async () => { + await composable.executeSearch('test'); + expect(composable.searchQuery.value).toBe('test'); + }); + + it('should clear results for empty query', async () => { + composable.searchResults.value = [0, 1, 2]; + await composable.executeSearch(''); + expect(composable.searchResults.value).toEqual([]); + }); + }); + + describe('performSynchronousSearch', () => { + it('should find matching lines', () => { + composable.searchQuery.value = 'hello'; + composable.performSearch = composable.performSearch.fn + ? composable.performSearch + : vi.fn(() => { + const results = []; + const searchTerm = composable.searchQuery.value.toLowerCase(); + fileLines.value.forEach((line, index) => { + if (line.toLowerCase().includes(searchTerm)) { + results.push(index); + } + }); + composable.searchResults.value = results; + }); + + // Manually call synchronous search for testing + const results = []; + const searchTerm = 'hello'; + fileLines.value.forEach((line, index) => { + if (line.toLowerCase().includes(searchTerm)) { + results.push(index); + } + }); + + expect(results.length).toBeGreaterThan(0); + expect(results).toContain(0); // 'const hello = "world";' + expect(results).toContain(1); // 'console.log("hello");' + expect(results).toContain(3); // 'return "hello world";' + }); + + it('should handle case-insensitive search', () => { + const searchTerm = 'FUNCTION'; + const results = []; + fileLines.value.forEach((line, index) => { + if (line.toLowerCase().includes(searchTerm.toLowerCase())) { + results.push(index); + } + }); + + expect(results).toContain(2); // 'function test() {' + }); + + it('should handle special regex characters gracefully', () => { + const searchTerm = '.*'; + const escapedTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); + expect(() => new RegExp(escapedTerm)).not.toThrow(); + }); + }); + + describe('search query reactivity', () => { + it('should trigger search when query changes', async () => { + const performSearchSpy = vi.spyOn(composable, 'performSearch'); + composable.searchQuery.value = 'test'; + + // Wait for debounce + await new Promise(resolve => setTimeout(resolve, 400)); + + expect(performSearchSpy).toHaveBeenCalled(); + }); + + it('should clear results when query becomes empty', async () => { + composable.searchResults.value = [0, 1, 2]; + composable.searchQuery.value = ''; + + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(composable.searchResults.value).toEqual([]); + }); + }); + + describe('error handling', () => { + it('should set error state on search failure', () => { + composable.searchError.value = 'Search failed'; + expect(composable.searchError.value).toBe('Search failed'); + }); + + it('should clear error on successful search', () => { + composable.searchError.value = 'Previous error'; + composable.clearSearch(); + // Error should be cleared by clearSearchResults + expect(composable.searchError.value).toBeNull(); + }); + }); +});