/ tests / unit / search / indexer.test.ts
indexer.test.ts
  1  /**
  2   * Tests for search indexer
  3   */
  4  import { describe, it, expect, beforeEach } from 'vitest';
  5  import {
  6    createSearchIndex,
  7    buildIndex,
  8    indexWorkspace,
  9    indexWorkspaceTabs,
 10    indexTab,
 11    updateWorkspaceInIndex,
 12    removeDocument,
 13    removeWorkspaceFromIndex,
 14    getIndexStats,
 15    serializeIndex,
 16    deserializeIndex,
 17  } from '../../../src/lib/search/indexer';
 18  import { search } from '../../../src/lib/search/searcher';
 19  import type { Workspace, StandaloneWorkspace, ChildWorkspace, SavedTab } from '../../../src/lib/types';
 20  import type { WorkspaceResource } from '../../../src/lib/types/resource';
 21  import type { SearchIndex } from '../../../src/lib/types/search';
 22  
 23  // Helper to create mock workspace
 24  function createMockWorkspace(id: string, name: string, tabs: SavedTab[] = []): StandaloneWorkspace {
 25    return {
 26      id,
 27      name,
 28      type: 'standalone',
 29      parentId: null,
 30      isTranscendent: false,
 31      tabs,
 32      metadata: {
 33        created: new Date().toISOString(),
 34        lastModified: new Date().toISOString(),
 35        lastAccessed: new Date().toISOString(),
 36        switchCount: 0,
 37        totalTimeActive: 0,
 38      },
 39    };
 40  }
 41  
 42  describe('createSearchIndex', () => {
 43    it('creates an empty index', () => {
 44      const index = createSearchIndex();
 45  
 46      expect(index.tokens.size).toBe(0);
 47      expect(index.documents.size).toBe(0);
 48      expect(index.stats.documentCount).toBe(0);
 49      expect(index.stats.tokenCount).toBe(0);
 50    });
 51  });
 52  
 53  describe('indexWorkspace', () => {
 54    let index: SearchIndex;
 55  
 56    beforeEach(() => {
 57      index = createSearchIndex();
 58    });
 59  
 60    it('adds workspace to index', () => {
 61      const workspace = createMockWorkspace('ws-1', 'Test Workspace');
 62      indexWorkspace(index, workspace);
 63  
 64      expect(index.documents.has('ws-1')).toBe(true);
 65      expect(index.stats.documentCount).toBe(1);
 66    });
 67  
 68    it('indexes workspace name tokens', () => {
 69      const workspace = createMockWorkspace('ws-1', 'React Project');
 70      indexWorkspace(index, workspace);
 71  
 72      // Should have indexed 'react' and 'project'
 73      expect(index.tokens.has('react')).toBe(true);
 74      expect(index.tokens.has('project')).toBe(true);
 75    });
 76  
 77    it('updates stats after indexing', () => {
 78      const workspace = createMockWorkspace('ws-1', 'Test');
 79      indexWorkspace(index, workspace);
 80  
 81      expect(index.stats.documentCount).toBeGreaterThan(0);
 82      expect(index.stats.lastUpdated).toBeTruthy();
 83    });
 84  });
 85  
 86  describe('indexTab', () => {
 87    let index: SearchIndex;
 88  
 89    beforeEach(() => {
 90      index = createSearchIndex();
 91    });
 92  
 93    it('adds tab to index', () => {
 94      const workspace = createMockWorkspace('ws-1', 'Test');
 95      const tab: SavedTab = {
 96        url: 'https://github.com/user/repo',
 97        title: 'GitHub Repository',
 98        pinned: false,
 99      };
100  
101      indexTab(index, tab, 0, workspace);
102  
103      expect(index.documents.has('ws-1:tab:0')).toBe(true);
104    });
105  
106    it('indexes tab title and URL', () => {
107      const workspace = createMockWorkspace('ws-1', 'Test');
108      const tab: SavedTab = {
109        url: 'https://github.com/user/repo',
110        title: 'GitHub Repository',
111        pinned: false,
112      };
113  
114      indexTab(index, tab, 0, workspace);
115  
116      expect(index.tokens.has('github')).toBe(true);
117      // 'repository' is stemmed by removing 'y' -> 'repositor'
118      expect(index.tokens.has('repository') || index.tokens.has('repositor')).toBe(true);
119    });
120  });
121  
122  describe('removeDocument', () => {
123    let index: SearchIndex;
124  
125    beforeEach(() => {
126      index = createSearchIndex();
127      const workspace = createMockWorkspace('ws-1', 'Test Workspace');
128      indexWorkspace(index, workspace);
129    });
130  
131    it('removes document from index', () => {
132      expect(index.documents.has('ws-1')).toBe(true);
133  
134      removeDocument(index, 'ws-1');
135  
136      expect(index.documents.has('ws-1')).toBe(false);
137    });
138  
139    it('cleans up token entries', () => {
140      const tokensBefore = index.tokens.size;
141      removeDocument(index, 'ws-1');
142  
143      // After removing the only document with these tokens,
144      // empty token entries should be cleaned up
145      expect(index.tokens.size).toBeLessThanOrEqual(tokensBefore);
146    });
147  });
148  
149  describe('removeWorkspaceFromIndex', () => {
150    let index: SearchIndex;
151  
152    beforeEach(() => {
153      index = createSearchIndex();
154      const workspace = createMockWorkspace('ws-1', 'Test', [
155        { url: 'https://example.com/1', title: 'Tab 1', pinned: false },
156        { url: 'https://example.com/2', title: 'Tab 2', pinned: false },
157      ]);
158      indexWorkspace(index, workspace);
159      workspace.tabs.forEach((tab, i) => indexTab(index, tab, i, workspace));
160    });
161  
162    it('removes workspace and all its tabs', () => {
163      expect(index.documents.size).toBe(3); // workspace + 2 tabs
164  
165      removeWorkspaceFromIndex(index, 'ws-1');
166  
167      expect(index.documents.size).toBe(0);
168    });
169  });
170  
171  describe('buildIndex', () => {
172    it('builds index from workspaces array', () => {
173      const workspaces: Workspace[] = [
174        createMockWorkspace('ws-1', 'First Workspace'),
175        createMockWorkspace('ws-2', 'Second Workspace'),
176      ];
177  
178      const index = buildIndex(workspaces);
179  
180      expect(index.documents.size).toBe(2);
181      expect(index.stats.documentCount).toBe(2);
182    });
183  
184    it('indexes tabs from tab workspaces', () => {
185      const workspaces: Workspace[] = [
186        createMockWorkspace('ws-1', 'Test', [
187          { url: 'https://example.com', title: 'Example', pinned: false },
188        ]),
189      ];
190  
191      const index = buildIndex(workspaces);
192  
193      // Should have workspace + 1 tab
194      expect(index.documents.size).toBe(2);
195    });
196  });
197  
198  describe('getIndexStats', () => {
199    it('returns correct statistics', () => {
200      const workspaces: Workspace[] = [
201        createMockWorkspace('ws-1', 'First', [
202          { url: 'https://example.com/1', title: 'Tab 1', pinned: false },
203          { url: 'https://example.com/2', title: 'Tab 2', pinned: false },
204        ]),
205        createMockWorkspace('ws-2', 'Second'),
206      ];
207  
208      const index = buildIndex(workspaces);
209      const stats = getIndexStats(index);
210  
211      expect(stats.workspaceCount).toBe(2);
212      expect(stats.tabCount).toBe(2);
213      expect(stats.documentCount).toBe(4); // 2 workspaces + 2 tabs
214    });
215  });
216  
217  describe('serializeIndex / deserializeIndex', () => {
218    it('serializes and deserializes index', () => {
219      const workspaces: Workspace[] = [
220        createMockWorkspace('ws-1', 'Test Workspace'),
221      ];
222  
223      const originalIndex = buildIndex(workspaces);
224      const serialized = serializeIndex(originalIndex);
225      const deserializedIndex = deserializeIndex(serialized);
226  
227      expect(deserializedIndex.documents.size).toBe(originalIndex.documents.size);
228      expect(deserializedIndex.tokens.size).toBe(originalIndex.tokens.size);
229      expect(deserializedIndex.stats.documentCount).toBe(originalIndex.stats.documentCount);
230    });
231  
232    it('produces valid JSON', () => {
233      const index = createSearchIndex();
234      indexWorkspace(index, createMockWorkspace('ws-1', 'Test'));
235  
236      const serialized = serializeIndex(index);
237  
238      expect(() => JSON.parse(serialized)).not.toThrow();
239    });
240  });
241  
242  describe('updateWorkspaceInIndex', () => {
243    let index: SearchIndex;
244  
245    beforeEach(() => {
246      // Build an index with a workspace that has tabs
247      const workspace = createMockWorkspace('ws-1', 'Original Name', [
248        { url: 'https://example.com', title: 'Example Page', pinned: false },
249      ]);
250      index = buildIndex([workspace]);
251    });
252  
253    it('updates workspace name in index after rename', () => {
254      // Verify old name is indexed (the indexer indexes the document directly)
255      expect(index.documents.get('ws-1')?.title).toBe('Original Name');
256  
257      // Simulate rename: create workspace with new name, same id
258      const renamed = createMockWorkspace('ws-1', 'Quantum Project', [
259        { url: 'https://example.com', title: 'Example Page', pinned: false },
260      ]);
261      updateWorkspaceInIndex(index, renamed);
262  
263      // New name should appear in document store
264      expect(index.documents.get('ws-1')?.title).toBe('Quantum Project');
265  
266      // New name tokens should be in the index
267      expect(index.tokens.has('quantum')).toBe(true);
268  
269      // Old exclusive token should be gone (only ws-1 had "original")
270      expect(index.tokens.has('original')).toBe(false);
271    });
272  
273    it('updates tabs in index after tab changes', () => {
274      // Verify old tab document exists
275      expect(index.documents.has('ws-1:tab:0')).toBe(true);
276      expect(index.documents.get('ws-1:tab:0')?.title).toBe('Example Page');
277  
278      // Simulate tab update: same workspace, different tabs
279      const updated = createMockWorkspace('ws-1', 'Original Name', [
280        { url: 'https://newsite.org', title: 'Quantum Portal', pinned: false },
281      ]);
282      updateWorkspaceInIndex(index, updated);
283  
284      // New tab should be in document store
285      expect(index.documents.get('ws-1:tab:0')?.title).toBe('Quantum Portal');
286  
287      // New tab tokens should be in the index
288      expect(index.tokens.has('quantum')).toBe(true);
289      expect(index.tokens.has('portal')).toBe(true);
290    });
291  
292    it('updates summary in index after summary change', () => {
293      // Initially no summary tokens
294      expect(index.tokens.has('quantum')).toBe(false);
295      expect(index.tokens.has('photon')).toBe(false);
296  
297      // Add summary to workspace
298      const withSummary = {
299        ...createMockWorkspace('ws-1', 'Original Name', [
300          { url: 'https://example.com', title: 'Example Page', pinned: false },
301        ]),
302        summary: { text: 'Research about quantum photon physics', isUserGenerated: false },
303      };
304      updateWorkspaceInIndex(index, withSummary as any);
305  
306      // Summary tokens should now be in the index
307      expect(index.tokens.has('quantum')).toBe(true);
308      expect(index.tokens.has('photon')).toBe(true);
309  
310      // Document should have the summary field
311      expect(index.documents.get('ws-1')?.summary).toBe('Research about quantum photon physics');
312    });
313  
314    it('preserves document count after update', () => {
315      // 1 workspace + 1 tab = 2 documents
316      expect(index.documents.size).toBe(2);
317  
318      const updated = createMockWorkspace('ws-1', 'Updated Name', [
319        { url: 'https://example.com', title: 'Example Page', pinned: false },
320      ]);
321      updateWorkspaceInIndex(index, updated);
322  
323      // Should still be 2 documents, not more (no duplicates)
324      expect(index.documents.size).toBe(2);
325    });
326  
327    it('handles adding more tabs during update', () => {
328      expect(index.documents.size).toBe(2); // ws + 1 tab
329  
330      const updated = createMockWorkspace('ws-1', 'Original Name', [
331        { url: 'https://example.com', title: 'Example Page', pinned: false },
332        { url: 'https://another.com', title: 'Quantum Portal', pinned: false },
333        { url: 'https://third.com', title: 'Photon Dashboard', pinned: false },
334      ]);
335      updateWorkspaceInIndex(index, updated);
336  
337      // 1 workspace + 3 tabs = 4 documents
338      expect(index.documents.size).toBe(4);
339  
340      // New tab documents should exist
341      expect(index.documents.has('ws-1:tab:1')).toBe(true);
342      expect(index.documents.has('ws-1:tab:2')).toBe(true);
343      expect(index.tokens.has('quantum')).toBe(true);
344      expect(index.tokens.has('photon')).toBe(true);
345    });
346  
347    it('handles removing all tabs during update', () => {
348      const updated = createMockWorkspace('ws-1', 'Original Name', []);
349      updateWorkspaceInIndex(index, updated);
350  
351      // Only workspace remains, no tabs
352      expect(index.documents.size).toBe(1);
353      expect(index.documents.has('ws-1')).toBe(true);
354    });
355  });
356  
357  describe('resource indexing', () => {
358    function createMockResource(id: string, title: string, value: string, type: 'url' | 'logseq' | 'attachment' = 'url'): WorkspaceResource {
359      return {
360        id,
361        type,
362        title,
363        value,
364        createdAt: new Date().toISOString(),
365      };
366    }
367  
368    function createWorkspaceWithResources(id: string, name: string, resources: WorkspaceResource[], tabs: SavedTab[] = []): StandaloneWorkspace & { resources: WorkspaceResource[] } {
369      return {
370        ...createMockWorkspace(id, name, tabs),
371        resources,
372      };
373    }
374  
375    it('indexes resource titles as searchable documents', () => {
376      const ws = createWorkspaceWithResources('ws-1', 'Main', [
377        createMockResource('r-1', 'FLX1s - FuriPhone FLX1s Linux Phone', 'https://furilabs.com/shop/flx1s/'),
378      ]);
379      const index = buildIndex([ws]);
380  
381      // Resource should be indexed as its own document
382      expect(index.documents.has('ws-1:resource:0')).toBe(true);
383      const doc = index.documents.get('ws-1:resource:0');
384      expect(doc?.type).toBe('resource');
385      expect(doc?.title).toBe('FLX1s - FuriPhone FLX1s Linux Phone');
386    });
387  
388    it('indexes resource URL tokens', () => {
389      const ws = createWorkspaceWithResources('ws-1', 'Main', [
390        createMockResource('r-1', 'FuriPhone Store', 'https://furilabs.com/shop/flx1s/'),
391      ]);
392      const index = buildIndex([ws]);
393  
394      // URL domain/path tokens should be in the index
395      expect(index.tokens.has('furilabs')).toBe(true);
396      expect(index.tokens.has('flx1s')).toBe(true);
397      expect(index.tokens.has('shop')).toBe(true);
398    });
399  
400    it('indexes resource title tokens', () => {
401      const ws = createWorkspaceWithResources('ws-1', 'Main', [
402        createMockResource('r-1', 'FLX1s FuriPhone Linux Phone', 'https://furilabs.com/shop/flx1s/'),
403      ]);
404      const index = buildIndex([ws]);
405  
406      expect(index.tokens.has('furiphone')).toBe(true);
407      expect(index.tokens.has('linux')).toBe(true);
408      expect(index.tokens.has('phone')).toBe(true);
409    });
410  
411    it('includes workspace context in resource documents', () => {
412      const ws = createWorkspaceWithResources('ws-1', 'Shopping', [
413        createMockResource('r-1', 'FuriPhone Store', 'https://furilabs.com/shop/flx1s/'),
414      ]);
415      const index = buildIndex([ws]);
416  
417      const doc = index.documents.get('ws-1:resource:0');
418      expect(doc?.workspaceName).toBe('Shopping');
419      expect(doc?.workspaceId).toBe('ws-1');
420    });
421  
422    it('counts resources alongside workspaces and tabs', () => {
423      const ws = createWorkspaceWithResources('ws-1', 'Main', [
424        createMockResource('r-1', 'Resource One', 'https://example.com'),
425        createMockResource('r-2', 'Resource Two', 'https://other.com'),
426      ], [
427        { url: 'https://tab.com', title: 'A Tab', pinned: false },
428      ]);
429      const index = buildIndex([ws]);
430  
431      // 1 workspace + 1 tab + 2 resources = 4 documents
432      expect(index.documents.size).toBe(4);
433    });
434  
435    it('removes resources when workspace is removed from index', () => {
436      const ws = createWorkspaceWithResources('ws-1', 'Main', [
437        createMockResource('r-1', 'Resource One', 'https://example.com'),
438      ], [
439        { url: 'https://tab.com', title: 'A Tab', pinned: false },
440      ]);
441      const index = buildIndex([ws]);
442  
443      expect(index.documents.size).toBe(3); // ws + tab + resource
444  
445      removeWorkspaceFromIndex(index, 'ws-1');
446  
447      expect(index.documents.size).toBe(0);
448    });
449  
450    it('updates resources when workspace is updated in index', () => {
451      const ws = createWorkspaceWithResources('ws-1', 'Main', [
452        createMockResource('r-1', 'Old Resource', 'https://old.com'),
453      ]);
454      const index = buildIndex([ws]);
455  
456      expect(index.documents.get('ws-1:resource:0')?.title).toBe('Old Resource');
457  
458      // Update with different resources
459      const updated = createWorkspaceWithResources('ws-1', 'Main', [
460        createMockResource('r-2', 'Quantum Resource', 'https://quantum.org'),
461      ]);
462      updateWorkspaceInIndex(index, updated);
463  
464      expect(index.documents.get('ws-1:resource:0')?.title).toBe('Quantum Resource');
465      expect(index.tokens.has('quantum')).toBe(true);
466    });
467  
468    it('indexes Logseq resources by page name', () => {
469      const ws = createWorkspaceWithResources('ws-1', 'Research', [
470        createMockResource('r-1', 'Project Notes', 'quantum-computing-notes', 'logseq'),
471      ]);
472      const index = buildIndex([ws]);
473  
474      // Logseq page name should be tokenized (hyphens split into words)
475      expect(index.tokens.has('quantum')).toBe(true);
476      // 'computing' stems to 'comput', 'notes' stems to 'not' (removes 'es')
477      expect(index.tokens.has('comput')).toBe(true);
478    });
479  });