@memberjunction/ng-search 5.22.0 → 5.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=search-results.component.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search-results.component.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/search-results.component.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Tests for SearchResultsComponent pure logic:
3
+ * - SR-1: FormatScore consistency
4
+ * - SR-4: Flat mode support
5
+ * - SR-5: Pagination logic
6
+ *
7
+ * We test the logic functions directly without Angular TestBed,
8
+ * extracting them to avoid Angular decorator/inject() issues.
9
+ */
10
+ import { describe, it, expect } from 'vitest';
11
+ // ── Extract and test pure logic functions from SearchResultsComponent ──
12
+ /** Mirrors SearchResultsComponent.FormatScore */
13
+ function FormatScore(score) {
14
+ return `${Math.round(score * 100)}%`;
15
+ }
16
+ /** Mirrors SearchResultsComponent.FormatTime */
17
+ function FormatTime(ms) {
18
+ if (ms < 1000)
19
+ return `${ms}ms`;
20
+ return `${(ms / 1000).toFixed(1)}s`;
21
+ }
22
+ /** Mirrors SearchResultsComponent.ExtractUrls */
23
+ function ExtractUrls(snippet) {
24
+ if (!snippet)
25
+ return [];
26
+ const urlRegex = /https?:\/\/[^\s,;)>"']+/g;
27
+ const matches = snippet.match(urlRegex);
28
+ return matches ? [...new Set(matches)] : [];
29
+ }
30
+ /** Mirrors SearchResultsComponent.TruncateUrl */
31
+ function TruncateUrl(url) {
32
+ try {
33
+ const parsed = new URL(url);
34
+ const path = parsed.pathname.length > 30
35
+ ? parsed.pathname.substring(0, 30) + '...'
36
+ : parsed.pathname;
37
+ return parsed.hostname + path;
38
+ }
39
+ catch {
40
+ return url.length > 50 ? url.substring(0, 50) + '...' : url;
41
+ }
42
+ }
43
+ /** Mirrors SearchResultsComponent pagination logic */
44
+ function getPagedResults(flatResults, currentPage, pageSize) {
45
+ const start = (currentPage - 1) * pageSize;
46
+ return flatResults.slice(start, start + pageSize);
47
+ }
48
+ function getTotalPages(totalResults, pageSize) {
49
+ return Math.ceil(totalResults / pageSize);
50
+ }
51
+ function getPageNumbers(currentPage, totalPages) {
52
+ if (totalPages <= 7)
53
+ return Array.from({ length: totalPages }, (_, i) => i + 1);
54
+ const pages = [1];
55
+ const start = Math.max(2, currentPage - 2);
56
+ const end = Math.min(totalPages - 1, currentPage + 2);
57
+ if (start > 2)
58
+ pages.push(-1);
59
+ for (let i = start; i <= end; i++)
60
+ pages.push(i);
61
+ if (end < totalPages - 1)
62
+ pages.push(-1);
63
+ pages.push(totalPages);
64
+ return pages;
65
+ }
66
+ /** Mirrors SearchResultsComponent group logic */
67
+ function getVisibleResults(group, isExpanded, maxPerGroup) {
68
+ if (isExpanded)
69
+ return group.Results;
70
+ return group.Results.slice(0, maxPerGroup);
71
+ }
72
+ function createResult(id, score) {
73
+ return {
74
+ ID: id, Title: `R${id}`, Snippet: '', EntityName: 'Test',
75
+ RecordID: `rec-${id}`, SourceType: 'entity', Score: score,
76
+ ScoreBreakdown: { Vector: score * 0.6, FullText: score * 0.4 },
77
+ Tags: [], SourceIcon: 'fa-solid fa-database', MatchedAt: new Date(),
78
+ };
79
+ }
80
+ // ═══════════════════════════════════════════
81
+ // SR-1: FormatScore consistency
82
+ // ═══════════════════════════════════════════
83
+ describe('FormatScore (SR-1)', () => {
84
+ it('should format 0.85 as 85%', () => {
85
+ expect(FormatScore(0.85)).toBe('85%');
86
+ });
87
+ it('should format 1.0 as 100%', () => {
88
+ expect(FormatScore(1.0)).toBe('100%');
89
+ });
90
+ it('should format 0 as 0%', () => {
91
+ expect(FormatScore(0)).toBe('0%');
92
+ });
93
+ it('should round to nearest integer', () => {
94
+ expect(FormatScore(0.856)).toBe('86%');
95
+ expect(FormatScore(0.854)).toBe('85%');
96
+ });
97
+ it('should produce same format for card header and expanded detail (SR-1 fix)', () => {
98
+ // Before the fix, expanded detail used (score * 100).toFixed(0)
99
+ // Now both use FormatScore(score) — same function, same output
100
+ const score = 0.7823;
101
+ expect(FormatScore(score)).toBe('78%');
102
+ });
103
+ it('should match ScoreBreakdown formatting when using same function', () => {
104
+ // Both overall and breakdown scores should use the same format
105
+ const overallScore = 0.85;
106
+ const vectorScore = 0.72;
107
+ expect(FormatScore(overallScore)).toBe('85%');
108
+ expect(FormatScore(vectorScore)).toBe('72%');
109
+ });
110
+ });
111
+ // ═══════════════════════════════════════════
112
+ // SR-5: Pagination
113
+ // ═══════════════════════════════════════════
114
+ describe('Pagination (SR-5)', () => {
115
+ const results = Array.from({ length: 35 }, (_, i) => createResult(String(i + 1), 1 - i * 0.02));
116
+ it('should calculate total pages correctly', () => {
117
+ expect(getTotalPages(35, 10)).toBe(4);
118
+ });
119
+ it('should return first page of results', () => {
120
+ const page = getPagedResults(results, 1, 10);
121
+ expect(page).toHaveLength(10);
122
+ expect(page[0].ID).toBe('1');
123
+ expect(page[9].ID).toBe('10');
124
+ });
125
+ it('should return correct results for page 2', () => {
126
+ const page = getPagedResults(results, 2, 10);
127
+ expect(page).toHaveLength(10);
128
+ expect(page[0].ID).toBe('11');
129
+ });
130
+ it('should return partial last page', () => {
131
+ const page = getPagedResults(results, 4, 10);
132
+ expect(page).toHaveLength(5); // 35 - 30 = 5
133
+ });
134
+ it('should return empty for out-of-range page', () => {
135
+ const page = getPagedResults(results, 100, 10);
136
+ expect(page).toHaveLength(0);
137
+ });
138
+ it('should show all pages when total is 7 or fewer', () => {
139
+ const pages = getPageNumbers(1, 5);
140
+ expect(pages).toEqual([1, 2, 3, 4, 5]);
141
+ expect(pages).not.toContain(-1);
142
+ });
143
+ it('should include ellipsis for many pages', () => {
144
+ const pages = getPageNumbers(8, 15);
145
+ expect(pages[0]).toBe(1);
146
+ expect(pages[pages.length - 1]).toBe(15);
147
+ expect(pages).toContain(-1);
148
+ });
149
+ it('should center current page in page numbers', () => {
150
+ const pages = getPageNumbers(8, 15);
151
+ expect(pages).toContain(8);
152
+ expect(pages).toContain(6); // current - 2
153
+ expect(pages).toContain(10); // current + 2
154
+ });
155
+ it('should handle single page', () => {
156
+ const pages = getPageNumbers(1, 1);
157
+ expect(pages).toEqual([1]);
158
+ });
159
+ it('should handle zero pages', () => {
160
+ expect(getTotalPages(0, 10)).toBe(0);
161
+ });
162
+ it('should handle exact page boundary (e.g. 20 results / 10 per page)', () => {
163
+ const exactResults = Array.from({ length: 20 }, (_, i) => createResult(String(i + 1), 1 - i * 0.02));
164
+ expect(getTotalPages(20, 10)).toBe(2);
165
+ const page1 = getPagedResults(exactResults, 1, 10);
166
+ expect(page1).toHaveLength(10);
167
+ expect(page1[0].ID).toBe('1');
168
+ const page2 = getPagedResults(exactResults, 2, 10);
169
+ expect(page2).toHaveLength(10);
170
+ expect(page2[0].ID).toBe('11');
171
+ // Page 3 should be empty since 20/10 = exactly 2 pages
172
+ const page3 = getPagedResults(exactResults, 3, 10);
173
+ expect(page3).toHaveLength(0);
174
+ });
175
+ it('should return all results on single page when count equals page size', () => {
176
+ const singlePageResults = Array.from({ length: 10 }, (_, i) => createResult(String(i + 1), 0.9));
177
+ expect(getTotalPages(10, 10)).toBe(1);
178
+ const page = getPagedResults(singlePageResults, 1, 10);
179
+ expect(page).toHaveLength(10);
180
+ const pages = getPageNumbers(1, 1);
181
+ expect(pages).toEqual([1]);
182
+ });
183
+ it('should produce correct page numbers at boundary of 7 pages', () => {
184
+ // Exactly 7 pages — should show all, no ellipsis
185
+ const pages7 = getPageNumbers(4, 7);
186
+ expect(pages7).toEqual([1, 2, 3, 4, 5, 6, 7]);
187
+ expect(pages7).not.toContain(-1);
188
+ // 8 pages — should include ellipsis
189
+ const pages8 = getPageNumbers(4, 8);
190
+ expect(pages8[0]).toBe(1);
191
+ expect(pages8[pages8.length - 1]).toBe(8);
192
+ });
193
+ });
194
+ // ═══════════════════════════════════════════
195
+ // Group visibility logic
196
+ // ═══════════════════════════════════════════
197
+ describe('Group visibility', () => {
198
+ const group = {
199
+ Label: 'Test', Icon: 'fa-test', SourceType: 'entity',
200
+ Results: Array.from({ length: 10 }, (_, i) => createResult(String(i), 0.9)),
201
+ TotalCount: 10,
202
+ };
203
+ it('should limit to maxPerGroup when collapsed', () => {
204
+ const visible = getVisibleResults(group, false, 3);
205
+ expect(visible).toHaveLength(3);
206
+ });
207
+ it('should show all when expanded', () => {
208
+ const visible = getVisibleResults(group, true, 3);
209
+ expect(visible).toHaveLength(10);
210
+ });
211
+ it('should show all when results <= maxPerGroup', () => {
212
+ const smallGroup = {
213
+ ...group,
214
+ Results: group.Results.slice(0, 2),
215
+ TotalCount: 2,
216
+ };
217
+ const visible = getVisibleResults(smallGroup, false, 5);
218
+ expect(visible).toHaveLength(2);
219
+ });
220
+ });
221
+ // ═══════════════════════════════════════════
222
+ // URL helpers
223
+ // ═══════════════════════════════════════════
224
+ describe('URL helpers', () => {
225
+ it('should extract URLs from text', () => {
226
+ const urls = ExtractUrls('Visit https://example.com and http://test.org/path');
227
+ expect(urls).toHaveLength(2);
228
+ expect(urls).toContain('https://example.com');
229
+ });
230
+ it('should return empty for no URLs', () => {
231
+ expect(ExtractUrls('no urls here')).toHaveLength(0);
232
+ expect(ExtractUrls('')).toHaveLength(0);
233
+ });
234
+ it('should deduplicate URLs', () => {
235
+ const urls = ExtractUrls('https://a.com and https://a.com again');
236
+ expect(urls).toHaveLength(1);
237
+ });
238
+ it('should truncate long URLs', () => {
239
+ const longUrl = 'https://example.com/' + 'a'.repeat(100);
240
+ const truncated = TruncateUrl(longUrl);
241
+ expect(truncated.length).toBeLessThan(longUrl.length);
242
+ expect(truncated).toContain('...');
243
+ });
244
+ it('should handle short URLs', () => {
245
+ const truncated = TruncateUrl('https://example.com/page');
246
+ expect(truncated).toBe('example.com/page');
247
+ });
248
+ });
249
+ // ═══════════════════════════════════════════
250
+ // FormatTime
251
+ // ═══════════════════════════════════════════
252
+ describe('FormatTime', () => {
253
+ it('should format sub-second times in ms', () => {
254
+ expect(FormatTime(450)).toBe('450ms');
255
+ });
256
+ it('should format times >= 1s in seconds', () => {
257
+ expect(FormatTime(1500)).toBe('1.5s');
258
+ });
259
+ it('should format exactly 1 second', () => {
260
+ expect(FormatTime(1000)).toBe('1.0s');
261
+ });
262
+ });
263
+ //# sourceMappingURL=search-results.component.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search-results.component.test.js","sourceRoot":"","sources":["../../src/__tests__/search-results.component.test.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Tests for SearchResultsComponent pure logic:\n * - SR-1: FormatScore consistency\n * - SR-4: Flat mode support\n * - SR-5: Pagination logic\n *\n * We test the logic functions directly without Angular TestBed,\n * extracting them to avoid Angular decorator/inject() issues.\n */\nimport { describe, it, expect } from 'vitest';\nimport { SearchResultItem, SearchResultGroup } from '../lib/search-types';\n\n// ── Extract and test pure logic functions from SearchResultsComponent ──\n\n/** Mirrors SearchResultsComponent.FormatScore */\nfunction FormatScore(score: number): string {\n return `${Math.round(score * 100)}%`;\n}\n\n/** Mirrors SearchResultsComponent.FormatTime */\nfunction FormatTime(ms: number): string {\n if (ms < 1000) return `${ms}ms`;\n return `${(ms / 1000).toFixed(1)}s`;\n}\n\n/** Mirrors SearchResultsComponent.ExtractUrls */\nfunction ExtractUrls(snippet: string): string[] {\n if (!snippet) return [];\n const urlRegex = /https?:\\/\\/[^\\s,;)>\"']+/g;\n const matches = snippet.match(urlRegex);\n return matches ? [...new Set(matches)] : [];\n}\n\n/** Mirrors SearchResultsComponent.TruncateUrl */\nfunction TruncateUrl(url: string): string {\n try {\n const parsed = new URL(url);\n const path = parsed.pathname.length > 30\n ? parsed.pathname.substring(0, 30) + '...'\n : parsed.pathname;\n return parsed.hostname + path;\n } catch {\n return url.length > 50 ? url.substring(0, 50) + '...' : url;\n }\n}\n\n/** Mirrors SearchResultsComponent pagination logic */\nfunction getPagedResults(flatResults: SearchResultItem[], currentPage: number, pageSize: number): SearchResultItem[] {\n const start = (currentPage - 1) * pageSize;\n return flatResults.slice(start, start + pageSize);\n}\n\nfunction getTotalPages(totalResults: number, pageSize: number): number {\n return Math.ceil(totalResults / pageSize);\n}\n\nfunction getPageNumbers(currentPage: number, totalPages: number): number[] {\n if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);\n const pages: number[] = [1];\n const start = Math.max(2, currentPage - 2);\n const end = Math.min(totalPages - 1, currentPage + 2);\n if (start > 2) pages.push(-1);\n for (let i = start; i <= end; i++) pages.push(i);\n if (end < totalPages - 1) pages.push(-1);\n pages.push(totalPages);\n return pages;\n}\n\n/** Mirrors SearchResultsComponent group logic */\nfunction getVisibleResults(\n group: SearchResultGroup,\n isExpanded: boolean,\n maxPerGroup: number\n): SearchResultItem[] {\n if (isExpanded) return group.Results;\n return group.Results.slice(0, maxPerGroup);\n}\n\nfunction createResult(id: string, score: number): SearchResultItem {\n return {\n ID: id, Title: `R${id}`, Snippet: '', EntityName: 'Test',\n RecordID: `rec-${id}`, SourceType: 'entity', Score: score,\n ScoreBreakdown: { Vector: score * 0.6, FullText: score * 0.4 },\n Tags: [], SourceIcon: 'fa-solid fa-database', MatchedAt: new Date(),\n };\n}\n\n// ═══════════════════════════════════════════\n// SR-1: FormatScore consistency\n// ═══════════════════════════════════════════\ndescribe('FormatScore (SR-1)', () => {\n it('should format 0.85 as 85%', () => {\n expect(FormatScore(0.85)).toBe('85%');\n });\n\n it('should format 1.0 as 100%', () => {\n expect(FormatScore(1.0)).toBe('100%');\n });\n\n it('should format 0 as 0%', () => {\n expect(FormatScore(0)).toBe('0%');\n });\n\n it('should round to nearest integer', () => {\n expect(FormatScore(0.856)).toBe('86%');\n expect(FormatScore(0.854)).toBe('85%');\n });\n\n it('should produce same format for card header and expanded detail (SR-1 fix)', () => {\n // Before the fix, expanded detail used (score * 100).toFixed(0)\n // Now both use FormatScore(score) — same function, same output\n const score = 0.7823;\n expect(FormatScore(score)).toBe('78%');\n });\n\n it('should match ScoreBreakdown formatting when using same function', () => {\n // Both overall and breakdown scores should use the same format\n const overallScore = 0.85;\n const vectorScore = 0.72;\n expect(FormatScore(overallScore)).toBe('85%');\n expect(FormatScore(vectorScore)).toBe('72%');\n });\n});\n\n// ═══════════════════════════════════════════\n// SR-5: Pagination\n// ═══════════════════════════════════════════\ndescribe('Pagination (SR-5)', () => {\n const results = Array.from({ length: 35 }, (_, i) => createResult(String(i + 1), 1 - i * 0.02));\n\n it('should calculate total pages correctly', () => {\n expect(getTotalPages(35, 10)).toBe(4);\n });\n\n it('should return first page of results', () => {\n const page = getPagedResults(results, 1, 10);\n expect(page).toHaveLength(10);\n expect(page[0].ID).toBe('1');\n expect(page[9].ID).toBe('10');\n });\n\n it('should return correct results for page 2', () => {\n const page = getPagedResults(results, 2, 10);\n expect(page).toHaveLength(10);\n expect(page[0].ID).toBe('11');\n });\n\n it('should return partial last page', () => {\n const page = getPagedResults(results, 4, 10);\n expect(page).toHaveLength(5); // 35 - 30 = 5\n });\n\n it('should return empty for out-of-range page', () => {\n const page = getPagedResults(results, 100, 10);\n expect(page).toHaveLength(0);\n });\n\n it('should show all pages when total is 7 or fewer', () => {\n const pages = getPageNumbers(1, 5);\n expect(pages).toEqual([1, 2, 3, 4, 5]);\n expect(pages).not.toContain(-1);\n });\n\n it('should include ellipsis for many pages', () => {\n const pages = getPageNumbers(8, 15);\n expect(pages[0]).toBe(1);\n expect(pages[pages.length - 1]).toBe(15);\n expect(pages).toContain(-1);\n });\n\n it('should center current page in page numbers', () => {\n const pages = getPageNumbers(8, 15);\n expect(pages).toContain(8);\n expect(pages).toContain(6); // current - 2\n expect(pages).toContain(10); // current + 2\n });\n\n it('should handle single page', () => {\n const pages = getPageNumbers(1, 1);\n expect(pages).toEqual([1]);\n });\n\n it('should handle zero pages', () => {\n expect(getTotalPages(0, 10)).toBe(0);\n });\n\n it('should handle exact page boundary (e.g. 20 results / 10 per page)', () => {\n const exactResults = Array.from({ length: 20 }, (_, i) => createResult(String(i + 1), 1 - i * 0.02));\n expect(getTotalPages(20, 10)).toBe(2);\n\n const page1 = getPagedResults(exactResults, 1, 10);\n expect(page1).toHaveLength(10);\n expect(page1[0].ID).toBe('1');\n\n const page2 = getPagedResults(exactResults, 2, 10);\n expect(page2).toHaveLength(10);\n expect(page2[0].ID).toBe('11');\n\n // Page 3 should be empty since 20/10 = exactly 2 pages\n const page3 = getPagedResults(exactResults, 3, 10);\n expect(page3).toHaveLength(0);\n });\n\n it('should return all results on single page when count equals page size', () => {\n const singlePageResults = Array.from({ length: 10 }, (_, i) => createResult(String(i + 1), 0.9));\n expect(getTotalPages(10, 10)).toBe(1);\n\n const page = getPagedResults(singlePageResults, 1, 10);\n expect(page).toHaveLength(10);\n\n const pages = getPageNumbers(1, 1);\n expect(pages).toEqual([1]);\n });\n\n it('should produce correct page numbers at boundary of 7 pages', () => {\n // Exactly 7 pages — should show all, no ellipsis\n const pages7 = getPageNumbers(4, 7);\n expect(pages7).toEqual([1, 2, 3, 4, 5, 6, 7]);\n expect(pages7).not.toContain(-1);\n\n // 8 pages — should include ellipsis\n const pages8 = getPageNumbers(4, 8);\n expect(pages8[0]).toBe(1);\n expect(pages8[pages8.length - 1]).toBe(8);\n });\n});\n\n// ═══════════════════════════════════════════\n// Group visibility logic\n// ═══════════════════════════════════════════\ndescribe('Group visibility', () => {\n const group: SearchResultGroup = {\n Label: 'Test', Icon: 'fa-test', SourceType: 'entity',\n Results: Array.from({ length: 10 }, (_, i) => createResult(String(i), 0.9)),\n TotalCount: 10,\n };\n\n it('should limit to maxPerGroup when collapsed', () => {\n const visible = getVisibleResults(group, false, 3);\n expect(visible).toHaveLength(3);\n });\n\n it('should show all when expanded', () => {\n const visible = getVisibleResults(group, true, 3);\n expect(visible).toHaveLength(10);\n });\n\n it('should show all when results <= maxPerGroup', () => {\n const smallGroup: SearchResultGroup = {\n ...group,\n Results: group.Results.slice(0, 2),\n TotalCount: 2,\n };\n const visible = getVisibleResults(smallGroup, false, 5);\n expect(visible).toHaveLength(2);\n });\n});\n\n// ═══════════════════════════════════════════\n// URL helpers\n// ═══════════════════════════════════════════\ndescribe('URL helpers', () => {\n it('should extract URLs from text', () => {\n const urls = ExtractUrls('Visit https://example.com and http://test.org/path');\n expect(urls).toHaveLength(2);\n expect(urls).toContain('https://example.com');\n });\n\n it('should return empty for no URLs', () => {\n expect(ExtractUrls('no urls here')).toHaveLength(0);\n expect(ExtractUrls('')).toHaveLength(0);\n });\n\n it('should deduplicate URLs', () => {\n const urls = ExtractUrls('https://a.com and https://a.com again');\n expect(urls).toHaveLength(1);\n });\n\n it('should truncate long URLs', () => {\n const longUrl = 'https://example.com/' + 'a'.repeat(100);\n const truncated = TruncateUrl(longUrl);\n expect(truncated.length).toBeLessThan(longUrl.length);\n expect(truncated).toContain('...');\n });\n\n it('should handle short URLs', () => {\n const truncated = TruncateUrl('https://example.com/page');\n expect(truncated).toBe('example.com/page');\n });\n});\n\n// ═══════════════════════════════════════════\n// FormatTime\n// ═══════════════════════════════════════════\ndescribe('FormatTime', () => {\n it('should format sub-second times in ms', () => {\n expect(FormatTime(450)).toBe('450ms');\n });\n\n it('should format times >= 1s in seconds', () => {\n expect(FormatTime(1500)).toBe('1.5s');\n });\n\n it('should format exactly 1 second', () => {\n expect(FormatTime(1000)).toBe('1.0s');\n });\n});\n"]}
@@ -153,6 +153,51 @@ describe('SearchService', () => {
153
153
  const tagFilter = filters.find(f => f.Category === 'Tags');
154
154
  expect(tagFilter.Options.length).toBeLessThanOrEqual(15);
155
155
  });
156
+ it('should generate correct tag filter options with counts and icon', () => {
157
+ const results = [
158
+ createMockResult('1', 'entity', 'Contacts', ['crm', 'active']),
159
+ createMockResult('2', 'entity', 'Companies', ['crm']),
160
+ createMockResult('3', 'file', 'Docs', ['internal']),
161
+ ];
162
+ const filters = service.BuildFilters(results);
163
+ const tagFilter = filters.find(f => f.Category === 'Tags');
164
+ expect(tagFilter).toBeDefined();
165
+ expect(tagFilter.MultiSelect).toBe(true);
166
+ // 'crm' appears in 2 results, should be sorted first
167
+ const crmOption = tagFilter.Options.find(o => o.Value === 'crm');
168
+ expect(crmOption).toBeDefined();
169
+ expect(crmOption.Count).toBe(2);
170
+ expect(crmOption.Icon).toBe('fa-solid fa-tag');
171
+ expect(crmOption.IsSelected).toBe(false);
172
+ // 'active' and 'internal' each appear once
173
+ const activeOption = tagFilter.Options.find(o => o.Value === 'active');
174
+ expect(activeOption.Count).toBe(1);
175
+ const internalOption = tagFilter.Options.find(o => o.Value === 'internal');
176
+ expect(internalOption.Count).toBe(1);
177
+ });
178
+ it('should produce an empty Tags filter when no results have tags', () => {
179
+ const results = [
180
+ createMockResult('1', 'entity', 'Contacts'),
181
+ createMockResult('2', 'entity', 'Companies'),
182
+ ];
183
+ const filters = service.BuildFilters(results);
184
+ const tagFilter = filters.find(f => f.Category === 'Tags');
185
+ expect(tagFilter).toBeDefined();
186
+ expect(tagFilter.Options).toHaveLength(0);
187
+ });
188
+ it('should sort tags by count descending', () => {
189
+ const results = [
190
+ createMockResult('1', 'entity', 'A', ['rare', 'common']),
191
+ createMockResult('2', 'entity', 'B', ['common']),
192
+ createMockResult('3', 'entity', 'C', ['common']),
193
+ ];
194
+ const filters = service.BuildFilters(results);
195
+ const tagFilter = filters.find(f => f.Category === 'Tags');
196
+ expect(tagFilter.Options[0].Value).toBe('common');
197
+ expect(tagFilter.Options[0].Count).toBe(3);
198
+ expect(tagFilter.Options[1].Value).toBe('rare');
199
+ expect(tagFilter.Options[1].Count).toBe(1);
200
+ });
156
201
  });
157
202
  describe('GetSourceIcon', () => {
158
203
  it('should return correct icon for known types', () => {
@@ -164,6 +209,108 @@ describe('SearchService', () => {
164
209
  expect(service.GetSourceIcon('unknown')).toContain('fa-circle');
165
210
  });
166
211
  });
212
+ // ═══════════════════════════════════════════════════════════════
213
+ // SR-3 Bug Fix: BuildFilters should preserve all options from full result set
214
+ // ═══════════════════════════════════════════════════════════════
215
+ describe('BuildFilters (SR-3: filter options from full result set)', () => {
216
+ it('should include all entity names even when results are filtered', () => {
217
+ // Full result set with 3 entities
218
+ const fullResults = [
219
+ createMockResult('1', 'entity', 'Contacts', ['crm']),
220
+ createMockResult('2', 'entity', 'Companies', ['crm']),
221
+ createMockResult('3', 'entity', 'Deals', ['sales']),
222
+ ];
223
+ // Build filters from FULL set (this is what the fix does)
224
+ const filters = service.BuildFilters(fullResults);
225
+ const entityFilter = filters.find(f => f.Category === 'Entity');
226
+ // All 3 entities should appear in filter options
227
+ expect(entityFilter?.Options).toHaveLength(3);
228
+ expect(entityFilter?.Options.map(o => o.Value)).toContain('Contacts');
229
+ expect(entityFilter?.Options.map(o => o.Value)).toContain('Companies');
230
+ expect(entityFilter?.Options.map(o => o.Value)).toContain('Deals');
231
+ });
232
+ it('should count tags correctly across all results', () => {
233
+ const results = [
234
+ createMockResult('1', 'entity', 'Contacts', ['crm', 'sales']),
235
+ createMockResult('2', 'entity', 'Companies', ['crm']),
236
+ createMockResult('3', 'entity', 'Deals', ['sales', 'pipeline']),
237
+ ];
238
+ const filters = service.BuildFilters(results);
239
+ const tagFilter = filters.find(f => f.Category === 'Tags');
240
+ // 'crm' appears in 2 results, 'sales' in 2, 'pipeline' in 1
241
+ expect(tagFilter?.Options).toHaveLength(3);
242
+ const crmOption = tagFilter?.Options.find(o => o.Value === 'crm');
243
+ expect(crmOption?.Count).toBe(2);
244
+ });
245
+ it('should not lose filter options when building from subset vs full set', () => {
246
+ const fullResults = [
247
+ createMockResult('1', 'entity', 'Contacts'),
248
+ createMockResult('2', 'entity', 'Companies'),
249
+ createMockResult('3', 'file', 'Documents'),
250
+ ];
251
+ // Simulating filtering to only Contacts
252
+ const filteredResults = fullResults.filter(r => r.EntityName === 'Contacts');
253
+ // Building from filtered set loses Companies and Documents
254
+ const filteredFilters = service.BuildFilters(filteredResults);
255
+ const filteredEntityFilter = filteredFilters.find(f => f.Category === 'Entity');
256
+ expect(filteredEntityFilter?.Options).toHaveLength(1); // Only Contacts
257
+ // Building from full set preserves all options (the fix)
258
+ const fullFilters = service.BuildFilters(fullResults);
259
+ const fullEntityFilter = fullFilters.find(f => f.Category === 'Entity');
260
+ expect(fullEntityFilter?.Options).toHaveLength(3); // Contacts + Companies + Documents
261
+ });
262
+ });
263
+ // ═══════════════════════════════════════════════════════════════
264
+ // SR-4 Bug Fix: GroupResults should sort within groups by score
265
+ // ═══════════════════════════════════════════════════════════════
266
+ describe('GroupResults (SR-4: grouping and sorting)', () => {
267
+ it('should group results by source type with correct labels', () => {
268
+ const results = [
269
+ { ...createMockResult('1', 'entity', 'Contacts'), Score: 0.9 },
270
+ { ...createMockResult('2', 'content-item', 'Items'), Score: 0.8 },
271
+ { ...createMockResult('3', 'entity', 'Companies'), Score: 0.7 },
272
+ ];
273
+ const groups = service.GroupResults(results);
274
+ expect(groups).toHaveLength(2);
275
+ const entityGroup = groups.find(g => g.SourceType === 'entity');
276
+ expect(entityGroup?.Label).toBe('Entity Records');
277
+ expect(entityGroup?.TotalCount).toBe(2);
278
+ const contentGroup = groups.find(g => g.SourceType === 'content-item');
279
+ expect(contentGroup?.Label).toBe('Content Items');
280
+ expect(contentGroup?.TotalCount).toBe(1);
281
+ });
282
+ it('should use fallback label for unknown source types', () => {
283
+ const results = [
284
+ createMockResult('1', 'entity', 'Test'),
285
+ ];
286
+ // Hack source type to unknown
287
+ results[0]['SourceType'] = 'custom-type';
288
+ const groups = service.GroupResults(results);
289
+ expect(groups[0].Label).toBe('custom-type'); // Falls back to raw type name
290
+ });
291
+ it('should use fallback icon for unknown source types', () => {
292
+ const results = [
293
+ createMockResult('1', 'entity', 'Test'),
294
+ ];
295
+ results[0]['SourceType'] = 'unknown-source';
296
+ const groups = service.GroupResults(results);
297
+ expect(groups[0].Icon).toBe('fa-solid fa-circle');
298
+ });
299
+ it('should handle mixed known and unknown source types', () => {
300
+ const results = [
301
+ createMockResult('1', 'entity', 'Contacts'),
302
+ createMockResult('2', 'entity', 'Companies'),
303
+ ];
304
+ results[1]['SourceType'] = 'custom-plugin';
305
+ const groups = service.GroupResults(results);
306
+ expect(groups).toHaveLength(2);
307
+ const entityGroup = groups.find(g => g.SourceType === 'entity');
308
+ expect(entityGroup?.Label).toBe('Entity Records');
309
+ const customGroup = groups.find(g => g.SourceType === 'custom-plugin');
310
+ expect(customGroup?.Label).toBe('custom-plugin');
311
+ expect(customGroup?.Icon).toBe('fa-solid fa-circle');
312
+ });
313
+ });
167
314
  });
168
315
  function createMockResult(id, sourceType, entityName, tags = []) {
169
316
  return {
@@ -1 +1 @@
1
- {"version":3,"file":"search.service.test.js","sourceRoot":"","sources":["../../src/__tests__/search.service.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { describe, it, expect, beforeEach, vi } from 'vitest';\nimport { Metadata } from '@memberjunction/core';\nimport { SearchService, RecentSearch } from '../lib/search.service';\nimport {\n SearchRequest,\n SearchResponse,\n SearchResultItem,\n SearchResultGroup,\n SearchFilter\n} from '../lib/search-types';\n\n/** Installs a fake Metadata.Provider with a mock ExecuteGQL that returns empty success */\nfunction installMockProvider(): ReturnType<typeof vi.fn> {\n const mockExecuteGQL = vi.fn().mockResolvedValue({\n SearchKnowledge: {\n Success: true,\n Results: [],\n TotalCount: 0,\n ElapsedMs: 1,\n SourceCounts: { Vector: 0, FullText: 0, Entity: 0 },\n }\n });\n (Metadata as unknown as Record<string, unknown>)['Provider'] = { ExecuteGQL: mockExecuteGQL };\n return mockExecuteGQL;\n}\n\ndescribe('SearchService', () => {\n let service: SearchService;\n\n beforeEach(() => {\n service = new SearchService();\n installMockProvider();\n });\n\n describe('ExecuteSearch', () => {\n it('should set IsSearching to true during search', async () => {\n const values: boolean[] = [];\n service.IsSearching$.subscribe(val => values.push(val));\n\n await service.ExecuteSearch({\n Query: 'test query',\n MaxResults: 10,\n ActiveFilters: {},\n IncludeSources: ['vector', 'fulltext', 'entity']\n });\n\n // Should have gone true then false\n expect(values).toContain(true);\n expect(values[values.length - 1]).toBe(false);\n });\n\n it('should return empty results for empty query', async () => {\n const response = await service.ExecuteSearch({\n Query: ' ',\n MaxResults: 10,\n ActiveFilters: {},\n IncludeSources: ['vector']\n });\n\n expect(response.Success).toBe(true);\n expect(response.Results).toHaveLength(0);\n expect(response.TotalCount).toBe(0);\n });\n\n it('should update SearchResults$ observable', async () => {\n await service.ExecuteSearch({\n Query: 'test',\n MaxResults: 10,\n ActiveFilters: {},\n IncludeSources: ['vector']\n });\n\n const result = service.SearchResults$.value;\n expect(result).not.toBeNull();\n expect(result?.Success).toBe(true);\n });\n\n it('should add to recent searches', async () => {\n await service.ExecuteSearch({\n Query: 'test query',\n MaxResults: 10,\n ActiveFilters: {},\n IncludeSources: ['vector']\n });\n\n const recent = service.RecentSearches$.value;\n expect(recent.length).toBe(1);\n expect(recent[0].Query).toBe('test query');\n });\n\n it('should deduplicate recent searches', async () => {\n const request: SearchRequest = {\n Query: 'test',\n MaxResults: 10,\n ActiveFilters: {},\n IncludeSources: ['vector']\n };\n\n await service.ExecuteSearch(request);\n await service.ExecuteSearch(request);\n\n const recent = service.RecentSearches$.value;\n expect(recent.length).toBe(1);\n });\n\n it('should limit recent searches to max 20', async () => {\n for (let i = 0; i < 25; i++) {\n await service.ExecuteSearch({\n Query: `query ${i}`,\n MaxResults: 10,\n ActiveFilters: {},\n IncludeSources: ['vector']\n });\n }\n\n expect(service.RecentSearches$.value.length).toBeLessThanOrEqual(20);\n });\n });\n\n describe('ClearResults', () => {\n it('should set SearchResults$ to null', async () => {\n await service.ExecuteSearch({\n Query: 'test',\n MaxResults: 10,\n ActiveFilters: {},\n IncludeSources: ['vector']\n });\n\n service.ClearResults();\n expect(service.SearchResults$.value).toBeNull();\n });\n });\n\n describe('ClearRecentSearches', () => {\n it('should clear all recent searches', async () => {\n await service.ExecuteSearch({\n Query: 'test',\n MaxResults: 10,\n ActiveFilters: {},\n IncludeSources: ['vector']\n });\n\n service.ClearRecentSearches();\n expect(service.RecentSearches$.value).toHaveLength(0);\n });\n });\n\n describe('GroupResults', () => {\n it('should group results by source type', () => {\n const results: SearchResultItem[] = [\n createMockResult('1', 'entity', 'Contacts'),\n createMockResult('2', 'entity', 'Contacts'),\n createMockResult('3', 'file', 'Documents'),\n createMockResult('4', 'web-page', 'Pages'),\n ];\n\n const groups = service.GroupResults(results);\n\n expect(groups).toHaveLength(3);\n const entityGroup = groups.find(g => g.SourceType === 'entity');\n expect(entityGroup?.Results).toHaveLength(2);\n expect(entityGroup?.Label).toBe('Entity Records');\n });\n\n it('should return empty array for empty input', () => {\n const groups = service.GroupResults([]);\n expect(groups).toHaveLength(0);\n });\n });\n\n describe('BuildFilters', () => {\n it('should build source type, entity, and tag filters', () => {\n const results: SearchResultItem[] = [\n createMockResult('1', 'entity', 'Contacts', ['customer']),\n createMockResult('2', 'file', 'Documents', ['internal']),\n ];\n\n const filters = service.BuildFilters(results);\n\n expect(filters).toHaveLength(3);\n const sourceFilter = filters.find(f => f.Category === 'Source Type');\n expect(sourceFilter?.Options).toHaveLength(2);\n });\n\n it('should limit tags filter to 15 options', () => {\n const tags = Array.from({ length: 20 }, (_, i) => `tag${i}`);\n const results = tags.map((tag, i) =>\n createMockResult(String(i), 'entity', 'Test', [tag])\n );\n\n const filters = service.BuildFilters(results);\n const tagFilter = filters.find(f => f.Category === 'Tags');\n expect(tagFilter!.Options.length).toBeLessThanOrEqual(15);\n });\n });\n\n describe('GetSourceIcon', () => {\n it('should return correct icon for known types', () => {\n expect(service.GetSourceIcon('entity')).toContain('fa-database');\n expect(service.GetSourceIcon('file')).toContain('fa-file');\n expect(service.GetSourceIcon('web-page')).toContain('fa-globe');\n });\n\n it('should return fallback icon for unknown types', () => {\n expect(service.GetSourceIcon('unknown')).toContain('fa-circle');\n });\n });\n});\n\nfunction createMockResult(\n id: string,\n sourceType: 'entity' | 'content-item' | 'file' | 'web-page',\n entityName: string,\n tags: string[] = []\n): SearchResultItem {\n return {\n ID: id,\n Title: `Result ${id}`,\n Snippet: `Snippet for result ${id}`,\n EntityName: entityName,\n RecordID: `rec-${id}`,\n SourceType: sourceType,\n Score: 0.85,\n ScoreBreakdown: { Vector: 0.8, FullText: 0.9 },\n Tags: tags,\n SourceIcon: 'fa-solid fa-database',\n MatchedAt: new Date()\n };\n}\n"]}
1
+ {"version":3,"file":"search.service.test.js","sourceRoot":"","sources":["../../src/__tests__/search.service.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { describe, it, expect, beforeEach, vi } from 'vitest';\nimport { Metadata } from '@memberjunction/core';\nimport { SearchService, RecentSearch } from '../lib/search.service';\nimport {\n SearchRequest,\n SearchResponse,\n SearchResultItem,\n SearchResultGroup,\n SearchFilter\n} from '../lib/search-types';\n\n/** Installs a fake Metadata.Provider with a mock ExecuteGQL that returns empty success */\nfunction installMockProvider(): ReturnType<typeof vi.fn> {\n const mockExecuteGQL = vi.fn().mockResolvedValue({\n SearchKnowledge: {\n Success: true,\n Results: [],\n TotalCount: 0,\n ElapsedMs: 1,\n SourceCounts: { Vector: 0, FullText: 0, Entity: 0 },\n }\n });\n (Metadata as unknown as Record<string, unknown>)['Provider'] = { ExecuteGQL: mockExecuteGQL };\n return mockExecuteGQL;\n}\n\ndescribe('SearchService', () => {\n let service: SearchService;\n\n beforeEach(() => {\n service = new SearchService();\n installMockProvider();\n });\n\n describe('ExecuteSearch', () => {\n it('should set IsSearching to true during search', async () => {\n const values: boolean[] = [];\n service.IsSearching$.subscribe(val => values.push(val));\n\n await service.ExecuteSearch({\n Query: 'test query',\n MaxResults: 10,\n ActiveFilters: {},\n IncludeSources: ['vector', 'fulltext', 'entity']\n });\n\n // Should have gone true then false\n expect(values).toContain(true);\n expect(values[values.length - 1]).toBe(false);\n });\n\n it('should return empty results for empty query', async () => {\n const response = await service.ExecuteSearch({\n Query: ' ',\n MaxResults: 10,\n ActiveFilters: {},\n IncludeSources: ['vector']\n });\n\n expect(response.Success).toBe(true);\n expect(response.Results).toHaveLength(0);\n expect(response.TotalCount).toBe(0);\n });\n\n it('should update SearchResults$ observable', async () => {\n await service.ExecuteSearch({\n Query: 'test',\n MaxResults: 10,\n ActiveFilters: {},\n IncludeSources: ['vector']\n });\n\n const result = service.SearchResults$.value;\n expect(result).not.toBeNull();\n expect(result?.Success).toBe(true);\n });\n\n it('should add to recent searches', async () => {\n await service.ExecuteSearch({\n Query: 'test query',\n MaxResults: 10,\n ActiveFilters: {},\n IncludeSources: ['vector']\n });\n\n const recent = service.RecentSearches$.value;\n expect(recent.length).toBe(1);\n expect(recent[0].Query).toBe('test query');\n });\n\n it('should deduplicate recent searches', async () => {\n const request: SearchRequest = {\n Query: 'test',\n MaxResults: 10,\n ActiveFilters: {},\n IncludeSources: ['vector']\n };\n\n await service.ExecuteSearch(request);\n await service.ExecuteSearch(request);\n\n const recent = service.RecentSearches$.value;\n expect(recent.length).toBe(1);\n });\n\n it('should limit recent searches to max 20', async () => {\n for (let i = 0; i < 25; i++) {\n await service.ExecuteSearch({\n Query: `query ${i}`,\n MaxResults: 10,\n ActiveFilters: {},\n IncludeSources: ['vector']\n });\n }\n\n expect(service.RecentSearches$.value.length).toBeLessThanOrEqual(20);\n });\n });\n\n describe('ClearResults', () => {\n it('should set SearchResults$ to null', async () => {\n await service.ExecuteSearch({\n Query: 'test',\n MaxResults: 10,\n ActiveFilters: {},\n IncludeSources: ['vector']\n });\n\n service.ClearResults();\n expect(service.SearchResults$.value).toBeNull();\n });\n });\n\n describe('ClearRecentSearches', () => {\n it('should clear all recent searches', async () => {\n await service.ExecuteSearch({\n Query: 'test',\n MaxResults: 10,\n ActiveFilters: {},\n IncludeSources: ['vector']\n });\n\n service.ClearRecentSearches();\n expect(service.RecentSearches$.value).toHaveLength(0);\n });\n });\n\n describe('GroupResults', () => {\n it('should group results by source type', () => {\n const results: SearchResultItem[] = [\n createMockResult('1', 'entity', 'Contacts'),\n createMockResult('2', 'entity', 'Contacts'),\n createMockResult('3', 'file', 'Documents'),\n createMockResult('4', 'web-page', 'Pages'),\n ];\n\n const groups = service.GroupResults(results);\n\n expect(groups).toHaveLength(3);\n const entityGroup = groups.find(g => g.SourceType === 'entity');\n expect(entityGroup?.Results).toHaveLength(2);\n expect(entityGroup?.Label).toBe('Entity Records');\n });\n\n it('should return empty array for empty input', () => {\n const groups = service.GroupResults([]);\n expect(groups).toHaveLength(0);\n });\n });\n\n describe('BuildFilters', () => {\n it('should build source type, entity, and tag filters', () => {\n const results: SearchResultItem[] = [\n createMockResult('1', 'entity', 'Contacts', ['customer']),\n createMockResult('2', 'file', 'Documents', ['internal']),\n ];\n\n const filters = service.BuildFilters(results);\n\n expect(filters).toHaveLength(3);\n const sourceFilter = filters.find(f => f.Category === 'Source Type');\n expect(sourceFilter?.Options).toHaveLength(2);\n });\n\n it('should limit tags filter to 15 options', () => {\n const tags = Array.from({ length: 20 }, (_, i) => `tag${i}`);\n const results = tags.map((tag, i) =>\n createMockResult(String(i), 'entity', 'Test', [tag])\n );\n\n const filters = service.BuildFilters(results);\n const tagFilter = filters.find(f => f.Category === 'Tags');\n expect(tagFilter!.Options.length).toBeLessThanOrEqual(15);\n });\n\n it('should generate correct tag filter options with counts and icon', () => {\n const results: SearchResultItem[] = [\n createMockResult('1', 'entity', 'Contacts', ['crm', 'active']),\n createMockResult('2', 'entity', 'Companies', ['crm']),\n createMockResult('3', 'file', 'Docs', ['internal']),\n ];\n\n const filters = service.BuildFilters(results);\n const tagFilter = filters.find(f => f.Category === 'Tags');\n expect(tagFilter).toBeDefined();\n expect(tagFilter!.MultiSelect).toBe(true);\n\n // 'crm' appears in 2 results, should be sorted first\n const crmOption = tagFilter!.Options.find(o => o.Value === 'crm');\n expect(crmOption).toBeDefined();\n expect(crmOption!.Count).toBe(2);\n expect(crmOption!.Icon).toBe('fa-solid fa-tag');\n expect(crmOption!.IsSelected).toBe(false);\n\n // 'active' and 'internal' each appear once\n const activeOption = tagFilter!.Options.find(o => o.Value === 'active');\n expect(activeOption!.Count).toBe(1);\n const internalOption = tagFilter!.Options.find(o => o.Value === 'internal');\n expect(internalOption!.Count).toBe(1);\n });\n\n it('should produce an empty Tags filter when no results have tags', () => {\n const results: SearchResultItem[] = [\n createMockResult('1', 'entity', 'Contacts'),\n createMockResult('2', 'entity', 'Companies'),\n ];\n\n const filters = service.BuildFilters(results);\n const tagFilter = filters.find(f => f.Category === 'Tags');\n expect(tagFilter).toBeDefined();\n expect(tagFilter!.Options).toHaveLength(0);\n });\n\n it('should sort tags by count descending', () => {\n const results: SearchResultItem[] = [\n createMockResult('1', 'entity', 'A', ['rare', 'common']),\n createMockResult('2', 'entity', 'B', ['common']),\n createMockResult('3', 'entity', 'C', ['common']),\n ];\n\n const filters = service.BuildFilters(results);\n const tagFilter = filters.find(f => f.Category === 'Tags');\n expect(tagFilter!.Options[0].Value).toBe('common');\n expect(tagFilter!.Options[0].Count).toBe(3);\n expect(tagFilter!.Options[1].Value).toBe('rare');\n expect(tagFilter!.Options[1].Count).toBe(1);\n });\n });\n\n describe('GetSourceIcon', () => {\n it('should return correct icon for known types', () => {\n expect(service.GetSourceIcon('entity')).toContain('fa-database');\n expect(service.GetSourceIcon('file')).toContain('fa-file');\n expect(service.GetSourceIcon('web-page')).toContain('fa-globe');\n });\n\n it('should return fallback icon for unknown types', () => {\n expect(service.GetSourceIcon('unknown')).toContain('fa-circle');\n });\n });\n\n // ═══════════════════════════════════════════════════════════════\n // SR-3 Bug Fix: BuildFilters should preserve all options from full result set\n // ═══════════════════════════════════════════════════════════════\n describe('BuildFilters (SR-3: filter options from full result set)', () => {\n it('should include all entity names even when results are filtered', () => {\n // Full result set with 3 entities\n const fullResults: SearchResultItem[] = [\n createMockResult('1', 'entity', 'Contacts', ['crm']),\n createMockResult('2', 'entity', 'Companies', ['crm']),\n createMockResult('3', 'entity', 'Deals', ['sales']),\n ];\n\n // Build filters from FULL set (this is what the fix does)\n const filters = service.BuildFilters(fullResults);\n const entityFilter = filters.find(f => f.Category === 'Entity');\n\n // All 3 entities should appear in filter options\n expect(entityFilter?.Options).toHaveLength(3);\n expect(entityFilter?.Options.map(o => o.Value)).toContain('Contacts');\n expect(entityFilter?.Options.map(o => o.Value)).toContain('Companies');\n expect(entityFilter?.Options.map(o => o.Value)).toContain('Deals');\n });\n\n it('should count tags correctly across all results', () => {\n const results: SearchResultItem[] = [\n createMockResult('1', 'entity', 'Contacts', ['crm', 'sales']),\n createMockResult('2', 'entity', 'Companies', ['crm']),\n createMockResult('3', 'entity', 'Deals', ['sales', 'pipeline']),\n ];\n\n const filters = service.BuildFilters(results);\n const tagFilter = filters.find(f => f.Category === 'Tags');\n\n // 'crm' appears in 2 results, 'sales' in 2, 'pipeline' in 1\n expect(tagFilter?.Options).toHaveLength(3);\n const crmOption = tagFilter?.Options.find(o => o.Value === 'crm');\n expect(crmOption?.Count).toBe(2);\n });\n\n it('should not lose filter options when building from subset vs full set', () => {\n const fullResults: SearchResultItem[] = [\n createMockResult('1', 'entity', 'Contacts'),\n createMockResult('2', 'entity', 'Companies'),\n createMockResult('3', 'file', 'Documents'),\n ];\n\n // Simulating filtering to only Contacts\n const filteredResults = fullResults.filter(r => r.EntityName === 'Contacts');\n\n // Building from filtered set loses Companies and Documents\n const filteredFilters = service.BuildFilters(filteredResults);\n const filteredEntityFilter = filteredFilters.find(f => f.Category === 'Entity');\n expect(filteredEntityFilter?.Options).toHaveLength(1); // Only Contacts\n\n // Building from full set preserves all options (the fix)\n const fullFilters = service.BuildFilters(fullResults);\n const fullEntityFilter = fullFilters.find(f => f.Category === 'Entity');\n expect(fullEntityFilter?.Options).toHaveLength(3); // Contacts + Companies + Documents\n });\n });\n\n // ═══════════════════════════════════════════════════════════════\n // SR-4 Bug Fix: GroupResults should sort within groups by score\n // ═══════════════════════════════════════════════════════════════\n describe('GroupResults (SR-4: grouping and sorting)', () => {\n it('should group results by source type with correct labels', () => {\n const results: SearchResultItem[] = [\n { ...createMockResult('1', 'entity', 'Contacts'), Score: 0.9 },\n { ...createMockResult('2', 'content-item', 'Items'), Score: 0.8 },\n { ...createMockResult('3', 'entity', 'Companies'), Score: 0.7 },\n ];\n\n const groups = service.GroupResults(results);\n expect(groups).toHaveLength(2);\n\n const entityGroup = groups.find(g => g.SourceType === 'entity');\n expect(entityGroup?.Label).toBe('Entity Records');\n expect(entityGroup?.TotalCount).toBe(2);\n\n const contentGroup = groups.find(g => g.SourceType === 'content-item');\n expect(contentGroup?.Label).toBe('Content Items');\n expect(contentGroup?.TotalCount).toBe(1);\n });\n\n it('should use fallback label for unknown source types', () => {\n const results: SearchResultItem[] = [\n createMockResult('1', 'entity', 'Test'),\n ];\n // Hack source type to unknown\n (results[0] as unknown as Record<string, unknown>)['SourceType'] = 'custom-type';\n\n const groups = service.GroupResults(results);\n expect(groups[0].Label).toBe('custom-type'); // Falls back to raw type name\n });\n\n it('should use fallback icon for unknown source types', () => {\n const results: SearchResultItem[] = [\n createMockResult('1', 'entity', 'Test'),\n ];\n (results[0] as unknown as Record<string, unknown>)['SourceType'] = 'unknown-source';\n\n const groups = service.GroupResults(results);\n expect(groups[0].Icon).toBe('fa-solid fa-circle');\n });\n\n it('should handle mixed known and unknown source types', () => {\n const results: SearchResultItem[] = [\n createMockResult('1', 'entity', 'Contacts'),\n createMockResult('2', 'entity', 'Companies'),\n ];\n (results[1] as unknown as Record<string, unknown>)['SourceType'] = 'custom-plugin';\n\n const groups = service.GroupResults(results);\n expect(groups).toHaveLength(2);\n\n const entityGroup = groups.find(g => g.SourceType === 'entity');\n expect(entityGroup?.Label).toBe('Entity Records');\n\n const customGroup = groups.find(g => g.SourceType === 'custom-plugin');\n expect(customGroup?.Label).toBe('custom-plugin');\n expect(customGroup?.Icon).toBe('fa-solid fa-circle');\n });\n });\n});\n\nfunction createMockResult(\n id: string,\n sourceType: 'entity' | 'content-item' | 'file' | 'web-page',\n entityName: string,\n tags: string[] = []\n): SearchResultItem {\n return {\n ID: id,\n Title: `Result ${id}`,\n Snippet: `Snippet for result ${id}`,\n EntityName: entityName,\n RecordID: `rec-${id}`,\n SourceType: sourceType,\n Score: 0.85,\n ScoreBreakdown: { Vector: 0.8, FullText: 0.9 },\n Tags: tags,\n SourceIcon: 'fa-solid fa-database',\n MatchedAt: new Date()\n };\n}\n"]}
@@ -17,6 +17,7 @@ export declare class SearchFilterComponent {
17
17
  Collapsible: boolean;
18
18
  FilterChanged: EventEmitter<SearchFilterChangeEvent>;
19
19
  FiltersCleared: EventEmitter<void>;
20
+ CloseRequested: EventEmitter<void>;
20
21
  /** Collapsed state per category */
21
22
  CollapsedCategories: Set<string>;
22
23
  /** Toggle a filter option */
@@ -33,7 +34,11 @@ export declare class SearchFilterComponent {
33
34
  GetActiveFilterCount(): number;
34
35
  /** Clear all filters */
35
36
  ClearAll(): void;
37
+ /** Select all options in a category, or clear all if already all selected */
38
+ SelectAll(category: string): void;
39
+ /** Check if all options in a category are selected */
40
+ IsAllSelected(category: string): boolean;
36
41
  static ɵfac: i0.ɵɵFactoryDeclaration<SearchFilterComponent, never>;
37
- static ɵcmp: i0.ɵɵComponentDeclaration<SearchFilterComponent, "mj-search-filter", never, { "Filters": { "alias": "Filters"; "required": false; }; "ActiveFilters": { "alias": "ActiveFilters"; "required": false; }; "Collapsible": { "alias": "Collapsible"; "required": false; }; }, { "FilterChanged": "FilterChanged"; "FiltersCleared": "FiltersCleared"; }, never, never, false, never>;
42
+ static ɵcmp: i0.ɵɵComponentDeclaration<SearchFilterComponent, "mj-search-filter", never, { "Filters": { "alias": "Filters"; "required": false; }; "ActiveFilters": { "alias": "ActiveFilters"; "required": false; }; "Collapsible": { "alias": "Collapsible"; "required": false; }; }, { "FilterChanged": "FilterChanged"; "FiltersCleared": "FiltersCleared"; "CloseRequested": "CloseRequested"; }, never, never, false, never>;
38
43
  }
39
44
  //# sourceMappingURL=search-filter.component.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"search-filter.component.d.ts","sourceRoot":"","sources":["../../src/lib/search-filter.component.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAEH,YAAY,EAKf,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,YAAY,EAAE,uBAAuB,EAAE,MAAM,gBAAgB,CAAC;;AAEvE,qBAMa,qBAAqB;IAC9B,OAAO,CAAC,GAAG,CAA6B;IAExC,mCAAmC;IAC1B,OAAO,EAAE,YAAY,EAAE,CAAM;IAEtC,yCAAyC;IAChC,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAM;IAEtD,8CAA8C;IACrC,WAAW,UAAQ;IAElB,aAAa,wCAA+C;IAC5D,cAAc,qBAA4B;IAEpD,mCAAmC;IAC5B,mBAAmB,cAAqB;IAE/C,6BAA6B;IACtB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI;IAc1D,+BAA+B;IACxB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAU7C,uCAAuC;IAChC,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO;IAIrD,qCAAqC;IAC9B,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO;IAIjE,sCAAsC;IAC/B,gBAAgB,IAAI,OAAO;IAIlC,oCAAoC;IAC7B,oBAAoB,IAAI,MAAM;IAIrC,wBAAwB;IACjB,QAAQ,IAAI,IAAI;yCAjEd,qBAAqB;2CAArB,qBAAqB;CAsEjC"}
1
+ {"version":3,"file":"search-filter.component.d.ts","sourceRoot":"","sources":["../../src/lib/search-filter.component.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAEH,YAAY,EAKf,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,YAAY,EAAE,uBAAuB,EAAE,MAAM,gBAAgB,CAAC;;AAEvE,qBAMa,qBAAqB;IAC9B,OAAO,CAAC,GAAG,CAA6B;IAExC,mCAAmC;IAC1B,OAAO,EAAE,YAAY,EAAE,CAAM;IAEtC,yCAAyC;IAChC,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAM;IAEtD,8CAA8C;IACrC,WAAW,UAAQ;IAElB,aAAa,wCAA+C;IAC5D,cAAc,qBAA4B;IAC1C,cAAc,qBAA4B;IAEpD,mCAAmC;IAC5B,mBAAmB,cAAqB;IAE/C,6BAA6B;IACtB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI;IAc1D,+BAA+B;IACxB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAU7C,uCAAuC;IAChC,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO;IAIrD,qCAAqC;IAC9B,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO;IAIjE,sCAAsC;IAC/B,gBAAgB,IAAI,OAAO;IAIlC,oCAAoC;IAC7B,oBAAoB,IAAI,MAAM;IAIrC,wBAAwB;IACjB,QAAQ,IAAI,IAAI;IAMvB,6EAA6E;IACtE,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAgBxC,sDAAsD;IAC/C,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO;yCA1FtC,qBAAqB;2CAArB,qBAAqB;CAgGjC"}