From c436455b324fe94542528d8c8e60fa8e410060a7 Mon Sep 17 00:00:00 2001 From: perf3ct Date: Sun, 3 Aug 2025 00:16:47 +0000 Subject: [PATCH] feat(tests): implement tests for updated fuzzy search operators, and text_utils used in search --- .../expressions/note_content_fulltext.spec.ts | 17 +++++ .../services/search/services/search.spec.ts | 25 +++++++ .../services/search/utils/text_utils.spec.ts | 65 +++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 apps/server/src/services/search/utils/text_utils.spec.ts diff --git a/apps/server/src/services/search/expressions/note_content_fulltext.spec.ts b/apps/server/src/services/search/expressions/note_content_fulltext.spec.ts index 9ef4a7d78..d0d51e087 100644 --- a/apps/server/src/services/search/expressions/note_content_fulltext.spec.ts +++ b/apps/server/src/services/search/expressions/note_content_fulltext.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { processMindmapContent } from "./note_content_fulltext.js"; +import NoteContentFulltextExp from "./note_content_fulltext.js"; describe("processMindmapContent", () => { it("supports empty JSON", () => { @@ -11,3 +12,19 @@ describe("processMindmapContent", () => { expect(processMindmapContent(`{ "node": " }`)).toEqual(""); }); }); + +describe("Fuzzy Search Operators", () => { + it("~= operator works with typos", () => { + // Test that the ~= operator can handle common typos + const expression = new NoteContentFulltextExp("~=", { tokens: ["hello"] }); + expect(expression.tokens).toEqual(["hello"]); + expect(() => new NoteContentFulltextExp("~=", { tokens: ["he"] })).toThrow(); // Too short + }); + + it("~* operator works with fuzzy contains", () => { + // Test that the ~* operator handles fuzzy substring matching + const expression = new NoteContentFulltextExp("~*", { tokens: ["world"] }); + expect(expression.tokens).toEqual(["world"]); + expect(() => new NoteContentFulltextExp("~*", { tokens: ["wo"] })).toThrow(); // Too short + }); +}); diff --git a/apps/server/src/services/search/services/search.spec.ts b/apps/server/src/services/search/services/search.spec.ts index fa62170a8..56ff278e8 100644 --- a/apps/server/src/services/search/services/search.spec.ts +++ b/apps/server/src/services/search/services/search.spec.ts @@ -553,6 +553,31 @@ describe("Search", () => { expect(becca.notes[searchResults[0].noteId].title).toEqual("Reddit is bad"); }); + it("search completes in reasonable time", () => { + // Create a moderate-sized dataset to test performance + const countries = ["Austria", "Belgium", "Croatia", "Denmark", "Estonia", "Finland", "Germany", "Hungary", "Ireland", "Japan"]; + const europeanCountries = note("Europe"); + + countries.forEach(country => { + europeanCountries.child(note(country).label("type", "country").label("continent", "Europe")); + }); + + rootNote.child(europeanCountries); + + const searchContext = new SearchContext(); + const startTime = Date.now(); + + // Perform a search that exercises multiple features + const searchResults = searchService.findResultsWithQuery("#type=country AND continent", searchContext); + + const endTime = Date.now(); + const duration = endTime - startTime; + + // Search should complete in under 1 second for reasonable dataset + expect(duration).toBeLessThan(1000); + expect(searchResults.length).toEqual(10); + }); + // FIXME: test what happens when we order without any filter criteria // it("comparison between labels", () => { diff --git a/apps/server/src/services/search/utils/text_utils.spec.ts b/apps/server/src/services/search/utils/text_utils.spec.ts new file mode 100644 index 000000000..a5f1da129 --- /dev/null +++ b/apps/server/src/services/search/utils/text_utils.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { calculateOptimizedEditDistance, validateFuzzySearchTokens, fuzzyMatchWord } from './text_utils.js'; + +describe('Fuzzy Search Core', () => { + describe('calculateOptimizedEditDistance', () => { + it('calculates edit distance for common typos', () => { + expect(calculateOptimizedEditDistance('hello', 'helo')).toBe(1); + expect(calculateOptimizedEditDistance('world', 'wrold')).toBe(2); + expect(calculateOptimizedEditDistance('cafe', 'café')).toBe(1); + expect(calculateOptimizedEditDistance('identical', 'identical')).toBe(0); + }); + + it('handles performance safety with oversized input', () => { + const longString = 'a'.repeat(2000); + const result = calculateOptimizedEditDistance(longString, 'short'); + expect(result).toBeGreaterThan(2); // Should use fallback heuristic + }); + }); + + describe('validateFuzzySearchTokens', () => { + it('validates minimum length requirements for fuzzy operators', () => { + const result1 = validateFuzzySearchTokens(['ab'], '~='); + expect(result1.isValid).toBe(false); + expect(result1.error).toContain('at least 3 characters'); + + const result2 = validateFuzzySearchTokens(['hello'], '~='); + expect(result2.isValid).toBe(true); + + const result3 = validateFuzzySearchTokens(['ok'], '='); + expect(result3.isValid).toBe(true); // Non-fuzzy operators allow short tokens + }); + + it('validates token types and empty arrays', () => { + expect(validateFuzzySearchTokens([], '=')).toEqual({ + isValid: false, + error: 'Invalid tokens: at least one token is required' + }); + + expect(validateFuzzySearchTokens([''], '=')).toEqual({ + isValid: false, + error: 'Invalid tokens: empty or whitespace-only tokens are not allowed' + }); + }); + }); + + describe('fuzzyMatchWord', () => { + it('matches words with diacritics normalization', () => { + expect(fuzzyMatchWord('cafe', 'café')).toBe(true); + expect(fuzzyMatchWord('naive', 'naïve')).toBe(true); + }); + + it('matches with typos within distance threshold', () => { + expect(fuzzyMatchWord('hello', 'helo')).toBe(true); + expect(fuzzyMatchWord('world', 'wrold')).toBe(true); + expect(fuzzyMatchWord('test', 'tset')).toBe(true); + expect(fuzzyMatchWord('test', 'xyz')).toBe(false); + }); + + it('handles edge cases safely', () => { + expect(fuzzyMatchWord('', 'test')).toBe(false); + expect(fuzzyMatchWord('test', '')).toBe(false); + expect(fuzzyMatchWord('a', 'b')).toBe(false); // Very short tokens + }); + }); +}); \ No newline at end of file