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();
+ });
+ });
+});