$results Results to return from search * @param bool $available Whether provider is available * @param int $priority Provider priority (lower = higher priority) */ function createMockSearchProvider( string $type, string $label, string $icon, array $results = [], bool $available = true, int $priority = 50 ): SearchProvider { return new class($type, $label, $icon, $results, $available, $priority) implements SearchProvider { use HasSearchProvider; public function __construct( protected string $type, protected string $label, protected string $icon, protected array $results, protected bool $available, protected int $priority ) {} public function searchType(): string { return $this->type; } public function searchLabel(): string { return $this->label; } public function searchIcon(): string { return $this->icon; } public function search(string $query, int $limit = 5): Collection { return collect($this->results)->take($limit); } public function getUrl(mixed $result): string { if ($result instanceof SearchResult) { return $result->url; } return $result['url'] ?? '#'; } public function searchPriority(): int { return $this->priority; } public function isAvailable(?object $user, ?object $workspace): bool { return $this->available; } }; } /** * Create a mock user object for testing. */ function createMockSearchUser(int $id = 1): object { return new class($id) { public function __construct(public int $id) {} }; } /** * Create a mock workspace object for testing. */ function createMockSearchWorkspace(int $id = 1, string $slug = 'test-workspace'): object { return new class($id, $slug) { public function __construct( public int $id, public string $slug ) {} }; } /** * Create a fresh registry instance for testing. */ function createSearchRegistry(): SearchProviderRegistry { return new SearchProviderRegistry; } // ============================================================================= // Provider Registration Tests // ============================================================================= describe('SearchProviderRegistry', function () { describe('provider registration', function () { it('returns empty array when no providers registered', function () { $registry = createSearchRegistry(); expect($registry->providers())->toBeArray() ->and($registry->providers())->toBeEmpty(); }); it('registers single provider', function () { $registry = createSearchRegistry(); $provider = createMockSearchProvider('pages', 'Pages', 'document'); $registry->register($provider); expect($registry->providers())->toHaveCount(1); }); it('registers multiple providers individually', function () { $registry = createSearchRegistry(); $provider1 = createMockSearchProvider('pages', 'Pages', 'document'); $provider2 = createMockSearchProvider('users', 'Users', 'user'); $registry->register($provider1); $registry->register($provider2); expect($registry->providers())->toHaveCount(2); }); it('registers multiple providers at once with registerMany', function () { $registry = createSearchRegistry(); $providers = [ createMockSearchProvider('pages', 'Pages', 'document'), createMockSearchProvider('users', 'Users', 'user'), createMockSearchProvider('posts', 'Posts', 'newspaper'), ]; $registry->registerMany($providers); expect($registry->providers())->toHaveCount(3); }); }); // ============================================================================= // Provider Availability Tests // ============================================================================= describe('provider availability', function () { it('returns all providers when all are available', function () { $registry = createSearchRegistry(); $registry->register(createMockSearchProvider('pages', 'Pages', 'document', [], true)); $registry->register(createMockSearchProvider('users', 'Users', 'user', [], true)); $available = $registry->availableProviders(null, null); expect($available)->toHaveCount(2); }); it('filters out unavailable providers', function () { $registry = createSearchRegistry(); $registry->register(createMockSearchProvider('pages', 'Pages', 'document', [], true)); $registry->register(createMockSearchProvider('admin', 'Admin', 'shield', [], false)); $available = $registry->availableProviders(null, null); expect($available)->toHaveCount(1); }); it('returns empty collection when no providers are available', function () { $registry = createSearchRegistry(); $registry->register(createMockSearchProvider('pages', 'Pages', 'document', [], false)); $registry->register(createMockSearchProvider('users', 'Users', 'user', [], false)); $available = $registry->availableProviders(null, null); expect($available)->toBeEmpty(); }); it('sorts available providers by priority', function () { $registry = createSearchRegistry(); $registry->register(createMockSearchProvider('low', 'Low Priority', 'down', [], true, 100)); $registry->register(createMockSearchProvider('high', 'High Priority', 'up', [], true, 10)); $registry->register(createMockSearchProvider('medium', 'Medium Priority', 'minus', [], true, 50)); $available = $registry->availableProviders(null, null); $types = $available->map(fn ($p) => $p->searchType())->values()->all(); expect($types)->toBe(['high', 'medium', 'low']); }); it('passes user and workspace to provider availability check', function () { $registry = createSearchRegistry(); $user = createMockSearchUser(1); $workspace = createMockSearchWorkspace(1, 'test'); // Create a provider that checks user/workspace $provider = new class implements SearchProvider { use HasSearchProvider; public ?object $receivedUser = null; public ?object $receivedWorkspace = null; public function searchType(): string { return 'test'; } public function searchLabel(): string { return 'Test'; } public function searchIcon(): string { return 'test'; } public function search(string $query, int $limit = 5): Collection { return collect(); } public function getUrl(mixed $result): string { return '#'; } public function isAvailable(?object $user, ?object $workspace): bool { $this->receivedUser = $user; $this->receivedWorkspace = $workspace; return true; } }; $registry->register($provider); $registry->availableProviders($user, $workspace); expect($provider->receivedUser)->toBe($user) ->and($provider->receivedWorkspace)->toBe($workspace); }); }); // ============================================================================= // Search Execution Tests // ============================================================================= describe('search execution', function () { it('returns empty array when no providers registered', function () { $registry = createSearchRegistry(); $results = $registry->search('test', null, null); expect($results)->toBeArray() ->and($results)->toBeEmpty(); }); it('returns empty array when no providers are available', function () { $registry = createSearchRegistry(); $registry->register(createMockSearchProvider('pages', 'Pages', 'document', [], false)); $results = $registry->search('test', null, null); expect($results)->toBeEmpty(); }); it('returns grouped results by search type', function () { $registry = createSearchRegistry(); $pageResults = [ new SearchResult('1', 'Dashboard', '/hub', 'pages', 'house', 'Overview'), new SearchResult('2', 'Settings', '/hub/settings', 'pages', 'gear', 'Preferences'), ]; $registry->register(createMockSearchProvider('pages', 'Pages', 'document', $pageResults)); $results = $registry->search('test', null, null); expect($results)->toHaveKey('pages') ->and($results['pages']['label'])->toBe('Pages') ->and($results['pages']['icon'])->toBe('document') ->and($results['pages']['results'])->toHaveCount(2); }); it('aggregates results from multiple providers', function () { $registry = createSearchRegistry(); $pageResults = [ new SearchResult('1', 'Dashboard', '/hub', 'pages', 'house'), ]; $userResults = [ new SearchResult('2', 'John Doe', '/users/1', 'users', 'user'), ]; $registry->register(createMockSearchProvider('pages', 'Pages', 'document', $pageResults)); $registry->register(createMockSearchProvider('users', 'Users', 'user', $userResults)); $results = $registry->search('test', null, null); expect($results)->toHaveKey('pages') ->and($results)->toHaveKey('users'); }); it('respects limit per provider', function () { $registry = createSearchRegistry(); $manyResults = []; for ($i = 1; $i <= 10; $i++) { $manyResults[] = new SearchResult((string) $i, "Result {$i}", "/result/{$i}", 'pages', 'document'); } $registry->register(createMockSearchProvider('pages', 'Pages', 'document', $manyResults)); $results = $registry->search('test', null, null, limitPerProvider: 3); expect($results['pages']['results'])->toHaveCount(3); }); it('handles SearchResult objects correctly', function () { $registry = createSearchRegistry(); $results = [ new SearchResult( id: 'test-1', title: 'Test Result', url: '/test', type: 'pages', icon: 'custom-icon', subtitle: 'A test result', meta: ['key' => 'value'] ), ]; $registry->register(createMockSearchProvider('pages', 'Pages', 'document', $results)); $searchResults = $registry->search('test', null, null); $firstResult = $searchResults['pages']['results'][0]; expect($firstResult['id'])->toBe('test-1') ->and($firstResult['title'])->toBe('Test Result') ->and($firstResult['url'])->toBe('/test') ->and($firstResult['subtitle'])->toBe('A test result') ->and($firstResult['meta'])->toBe(['key' => 'value']); }); it('handles array results correctly', function () { $registry = createSearchRegistry(); $results = [ [ 'id' => 'arr-1', 'title' => 'Array Result', 'url' => '/array', 'subtitle' => 'From array', ], ]; $registry->register(createMockSearchProvider('pages', 'Pages', 'document', $results)); $searchResults = $registry->search('test', null, null); $firstResult = $searchResults['pages']['results'][0]; expect($firstResult['id'])->toBe('arr-1') ->and($firstResult['title'])->toBe('Array Result') ->and($firstResult['type'])->toBe('pages') ->and($firstResult['icon'])->toBe('document'); }); it('handles model-like objects with id and title properties', function () { $registry = createSearchRegistry(); $modelResult = new class { public string $id = 'model-1'; public string $title = 'Model Title'; public string $description = 'Model Description'; }; // Create a provider that returns model objects $provider = new class($modelResult) implements SearchProvider { use HasSearchProvider; public function __construct(private object $model) {} public function searchType(): string { return 'models'; } public function searchLabel(): string { return 'Models'; } public function searchIcon(): string { return 'cube'; } public function search(string $query, int $limit = 5): Collection { return collect([$this->model]); } public function getUrl(mixed $result): string { return '/models/'.$result->id; } }; $registry->register($provider); $searchResults = $registry->search('test', null, null); $firstResult = $searchResults['models']['results'][0]; expect($firstResult['id'])->toBe('model-1') ->and($firstResult['title'])->toBe('Model Title') ->and($firstResult['subtitle'])->toBe('Model Description') ->and($firstResult['url'])->toBe('/models/model-1'); }); it('skips providers with empty results', function () { $registry = createSearchRegistry(); $registry->register(createMockSearchProvider('pages', 'Pages', 'document', [])); $registry->register(createMockSearchProvider('users', 'Users', 'user', [ new SearchResult('1', 'User', '/user', 'users', 'user'), ])); $results = $registry->search('test', null, null); expect($results)->not->toHaveKey('pages') ->and($results)->toHaveKey('users'); }); }); // ============================================================================= // Result Flattening Tests // ============================================================================= describe('result flattening', function () { it('flattens empty grouped results to empty array', function () { $registry = createSearchRegistry(); $flat = $registry->flattenResults([]); expect($flat)->toBeArray() ->and($flat)->toBeEmpty(); }); it('flattens single group results', function () { $registry = createSearchRegistry(); $grouped = [ 'pages' => [ 'label' => 'Pages', 'icon' => 'document', 'results' => [ ['id' => '1', 'title' => 'Dashboard'], ['id' => '2', 'title' => 'Settings'], ], ], ]; $flat = $registry->flattenResults($grouped); expect($flat)->toHaveCount(2) ->and($flat[0]['title'])->toBe('Dashboard') ->and($flat[1]['title'])->toBe('Settings'); }); it('flattens multiple group results in order', function () { $registry = createSearchRegistry(); $grouped = [ 'pages' => [ 'label' => 'Pages', 'icon' => 'document', 'results' => [ ['id' => '1', 'title' => 'Dashboard'], ['id' => '2', 'title' => 'Settings'], ], ], 'users' => [ 'label' => 'Users', 'icon' => 'user', 'results' => [ ['id' => '3', 'title' => 'Admin'], ['id' => '4', 'title' => 'Editor'], ], ], ]; $flat = $registry->flattenResults($grouped); expect($flat)->toHaveCount(4) ->and($flat[0]['title'])->toBe('Dashboard') ->and($flat[1]['title'])->toBe('Settings') ->and($flat[2]['title'])->toBe('Admin') ->and($flat[3]['title'])->toBe('Editor'); }); it('preserves all result properties when flattening', function () { $registry = createSearchRegistry(); $grouped = [ 'pages' => [ 'label' => 'Pages', 'icon' => 'document', 'results' => [ [ 'id' => '1', 'title' => 'Dashboard', 'subtitle' => 'Overview', 'url' => '/hub', 'type' => 'pages', 'icon' => 'house', 'meta' => ['featured' => true], ], ], ], ]; $flat = $registry->flattenResults($grouped); expect($flat[0])->toBe([ 'id' => '1', 'title' => 'Dashboard', 'subtitle' => 'Overview', 'url' => '/hub', 'type' => 'pages', 'icon' => 'house', 'meta' => ['featured' => true], ]); }); }); // ============================================================================= // Fuzzy Matching Tests // ============================================================================= describe('fuzzy matching', function () { it('matches direct substring', function () { $registry = createSearchRegistry(); expect($registry->fuzzyMatch('dash', 'Dashboard'))->toBeTrue() ->and($registry->fuzzyMatch('board', 'Dashboard'))->toBeTrue() ->and($registry->fuzzyMatch('settings', 'Account Settings'))->toBeTrue(); }); it('matches case insensitively', function () { $registry = createSearchRegistry(); expect($registry->fuzzyMatch('DASH', 'dashboard'))->toBeTrue() ->and($registry->fuzzyMatch('Dashboard', 'DASHBOARD'))->toBeTrue() ->and($registry->fuzzyMatch('sEtTiNgS', 'Settings'))->toBeTrue(); }); it('matches word-start abbreviations', function () { $registry = createSearchRegistry(); // "gs" matches "Global Search" (G + S) expect($registry->fuzzyMatch('gs', 'Global Search'))->toBeTrue(); // "ps" matches "Post Settings" expect($registry->fuzzyMatch('ps', 'Post Settings'))->toBeTrue(); // "ul" matches "Usage Limits" expect($registry->fuzzyMatch('ul', 'Usage Limits'))->toBeTrue(); }); it('matches character-by-character abbreviations', function () { $registry = createSearchRegistry(); // Characters appear in order expect($registry->fuzzyMatch('dbd', 'dashboard'))->toBeTrue() ->and($registry->fuzzyMatch('gsr', 'global search results'))->toBeTrue(); }); it('returns false for empty query', function () { $registry = createSearchRegistry(); expect($registry->fuzzyMatch('', 'Dashboard'))->toBeFalse() ->and($registry->fuzzyMatch(' ', 'Dashboard'))->toBeFalse(); }); it('returns false for non-matching query', function () { $registry = createSearchRegistry(); expect($registry->fuzzyMatch('xyz', 'Dashboard'))->toBeFalse() ->and($registry->fuzzyMatch('zzz', 'Settings'))->toBeFalse(); }); it('trims whitespace from query and target', function () { $registry = createSearchRegistry(); expect($registry->fuzzyMatch(' dash ', ' Dashboard '))->toBeTrue(); }); }); // ============================================================================= // Relevance Scoring Tests // ============================================================================= describe('relevance scoring', function () { it('scores exact match highest (100)', function () { $registry = createSearchRegistry(); $score = $registry->relevanceScore('dashboard', 'dashboard'); expect($score)->toBe(100); }); it('scores starts-with second highest (90)', function () { $registry = createSearchRegistry(); $score = $registry->relevanceScore('dash', 'dashboard'); expect($score)->toBe(90); }); it('scores whole-word match third highest (80)', function () { $registry = createSearchRegistry(); $score = $registry->relevanceScore('search', 'global search results'); expect($score)->toBe(80); }); it('scores substring match fourth (70)', function () { $registry = createSearchRegistry(); $score = $registry->relevanceScore('board', 'dashboard'); expect($score)->toBe(70); }); it('scores word-start match fifth (60)', function () { $registry = createSearchRegistry(); $score = $registry->relevanceScore('gs', 'global search'); expect($score)->toBe(60); }); it('scores fuzzy match lowest (40)', function () { $registry = createSearchRegistry(); // "gsr" fuzzy matches "global search results" but not as word-start $score = $registry->relevanceScore('gsr', 'global search results'); expect($score)->toBe(40); }); it('returns zero for no match', function () { $registry = createSearchRegistry(); $score = $registry->relevanceScore('xyz', 'dashboard'); expect($score)->toBe(0); }); it('returns zero for empty query', function () { $registry = createSearchRegistry(); expect($registry->relevanceScore('', 'dashboard'))->toBe(0) ->and($registry->relevanceScore('dash', ''))->toBe(0); }); it('handles case insensitivity in scoring', function () { $registry = createSearchRegistry(); expect($registry->relevanceScore('DASHBOARD', 'dashboard'))->toBe(100) ->and($registry->relevanceScore('Dashboard', 'DASHBOARD'))->toBe(100); }); }); }); // ============================================================================= // SearchResult Tests // ============================================================================= describe('SearchResult', function () { describe('construction', function () { it('creates result with all properties', function () { $result = new SearchResult( id: '123', title: 'Dashboard', url: '/hub', type: 'pages', icon: 'house', subtitle: 'Overview and quick actions', meta: ['key' => 'value'], ); expect($result->id)->toBe('123') ->and($result->title)->toBe('Dashboard') ->and($result->url)->toBe('/hub') ->and($result->type)->toBe('pages') ->and($result->icon)->toBe('house') ->and($result->subtitle)->toBe('Overview and quick actions') ->and($result->meta)->toBe(['key' => 'value']); }); it('allows null subtitle', function () { $result = new SearchResult( id: '1', title: 'Test', url: '/test', type: 'test', icon: 'test', ); expect($result->subtitle)->toBeNull(); }); it('defaults meta to empty array', function () { $result = new SearchResult( id: '1', title: 'Test', url: '/test', type: 'test', icon: 'test', ); expect($result->meta)->toBe([]); }); }); describe('fromArray factory', function () { it('creates result from complete array', function () { $data = [ 'id' => '456', 'title' => 'Settings', 'url' => '/hub/settings', 'type' => 'pages', 'icon' => 'gear', 'subtitle' => 'Account settings', 'meta' => ['order' => 2], ]; $result = SearchResult::fromArray($data); expect($result->id)->toBe('456') ->and($result->title)->toBe('Settings') ->and($result->url)->toBe('/hub/settings') ->and($result->type)->toBe('pages') ->and($result->icon)->toBe('gear') ->and($result->subtitle)->toBe('Account settings') ->and($result->meta)->toBe(['order' => 2]); }); it('generates ID when missing', function () { $result = SearchResult::fromArray(['title' => 'Test']); expect($result->id)->not->toBeEmpty(); }); it('uses sensible defaults for missing fields', function () { $result = SearchResult::fromArray(['title' => 'Minimal']); expect($result->title)->toBe('Minimal') ->and($result->url)->toBe('#') ->and($result->type)->toBe('unknown') ->and($result->icon)->toBe('document') ->and($result->subtitle)->toBeNull() ->and($result->meta)->toBe([]); }); }); describe('toArray conversion', function () { it('converts to array with all properties', function () { $result = new SearchResult( id: '789', title: 'Test', url: '/test', type: 'test', icon: 'test-icon', subtitle: 'Test subtitle', meta: ['foo' => 'bar'], ); $array = $result->toArray(); expect($array)->toBe([ 'id' => '789', 'title' => 'Test', 'subtitle' => 'Test subtitle', 'url' => '/test', 'type' => 'test', 'icon' => 'test-icon', 'meta' => ['foo' => 'bar'], ]); }); }); describe('JSON serialisation', function () { it('serialises to JSON correctly', function () { $result = new SearchResult( id: '1', title: 'JSON Test', url: '/json', type: 'json', icon: 'code', ); $json = json_encode($result); $decoded = json_decode($json, true); expect($decoded['id'])->toBe('1') ->and($decoded['title'])->toBe('JSON Test') ->and($decoded['url'])->toBe('/json'); }); }); describe('withTypeAndIcon', function () { it('creates new instance with updated type and icon', function () { $original = new SearchResult( id: '1', title: 'Test', url: '/test', type: 'old-type', icon: 'document', ); $modified = $original->withTypeAndIcon('new-type', 'new-icon'); // Original should be unchanged (immutable) expect($original->type)->toBe('old-type') ->and($original->icon)->toBe('document'); // Modified should have new values expect($modified->type)->toBe('new-type') ->and($modified->icon)->toBe('new-icon'); }); it('preserves custom icon when not using default', function () { $original = new SearchResult( id: '1', title: 'Test', url: '/test', type: 'old-type', icon: 'custom-icon', ); $modified = $original->withTypeAndIcon('new-type', 'fallback-icon'); // Should keep the custom icon, not use the fallback expect($modified->icon)->toBe('custom-icon') ->and($modified->type)->toBe('new-type'); }); it('preserves all other properties', function () { $original = new SearchResult( id: 'original-id', title: 'Original Title', url: '/original', type: 'old', icon: 'document', subtitle: 'Original subtitle', meta: ['preserved' => true], ); $modified = $original->withTypeAndIcon('new', 'new-icon'); expect($modified->id)->toBe('original-id') ->and($modified->title)->toBe('Original Title') ->and($modified->url)->toBe('/original') ->and($modified->subtitle)->toBe('Original subtitle') ->and($modified->meta)->toBe(['preserved' => true]); }); }); }); // ============================================================================= // Integration Tests // ============================================================================= describe('Search System Integration', function () { it('performs end-to-end search with multiple providers', function () { $registry = createSearchRegistry(); // Provider 1: Pages (high priority) $pageResults = [ new SearchResult('page-1', 'Dashboard', '/hub', 'pages', 'house', 'Main dashboard'), new SearchResult('page-2', 'Dashboard Settings', '/hub/settings', 'pages', 'gear', 'Configure dashboard'), ]; $registry->register(createMockSearchProvider('pages', 'Pages', 'rectangle-stack', $pageResults, true, 10)); // Provider 2: Users (medium priority) $userResults = [ new SearchResult('user-1', 'Admin Dashboard User', '/users/admin', 'users', 'user', 'Administrator'), ]; $registry->register(createMockSearchProvider('users', 'Users', 'users', $userResults, true, 50)); // Provider 3: Posts (low priority, unavailable) $postResults = [ new SearchResult('post-1', 'Dashboard Guide', '/posts/guide', 'posts', 'newspaper'), ]; $registry->register(createMockSearchProvider('posts', 'Posts', 'newspaper', $postResults, false, 100)); // Execute search $user = createMockSearchUser(1); $workspace = createMockSearchWorkspace(1, 'test'); $results = $registry->search('dashboard', $user, $workspace); // Verify structure expect($results)->toHaveKey('pages') ->and($results)->toHaveKey('users') ->and($results)->not->toHaveKey('posts'); // Unavailable provider excluded // Verify pages results expect($results['pages']['label'])->toBe('Pages') ->and($results['pages']['results'])->toHaveCount(2); // Verify users results expect($results['users']['label'])->toBe('Users') ->and($results['users']['results'])->toHaveCount(1); // Flatten for keyboard navigation $flat = $registry->flattenResults($results); expect($flat)->toHaveCount(3); }); it('supports workspace-scoped search providers', function () { $registry = createSearchRegistry(); // Provider that only works in specific workspace $provider = new class implements SearchProvider { use HasSearchProvider; public function searchType(): string { return 'workspace-items'; } public function searchLabel(): string { return 'Workspace Items'; } public function searchIcon(): string { return 'folder'; } public function search(string $query, int $limit = 5): Collection { return collect([ new SearchResult('ws-1', 'Workspace Item', '/item', 'workspace-items', 'folder'), ]); } public function getUrl(mixed $result): string { return $result->url ?? '#'; } public function isAvailable(?object $user, ?object $workspace): bool { // Only available when workspace slug is 'allowed-workspace' return $workspace !== null && $workspace->slug === 'allowed-workspace'; } }; $registry->register($provider); // Test with disallowed workspace $disallowedWorkspace = createMockSearchWorkspace(1, 'other-workspace'); $results = $registry->search('item', null, $disallowedWorkspace); expect($results)->toBeEmpty(); // Test with allowed workspace $allowedWorkspace = createMockSearchWorkspace(2, 'allowed-workspace'); $results = $registry->search('item', null, $allowedWorkspace); expect($results)->toHaveKey('workspace-items'); }); });