Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2806 (2024-05-06 14:55:13)

test: Add some tests for dependency suggestions
# Description

## Motivation and Context

- Replace an Obsidian-specific `string.contains()` with `string.includes()` which is available when run via tests.
- Add some initial tests to capture the behaviour of auto-suggest facilities added in #2771.
- Add a fake implementation of `prepareSimpleSearch()` - with its own tests - so that I could test suggestions made for the dependsOn field.

## How has this been tested?

- Exploratory testing to confirm the actual behaviours of the tests I've added
- Automated tests

## Types of changes

Internal changes:

- [x] **Refactor** (prefix: `refactor` - non-breaking change which only improves the design or structure of existing code, and making no changes to its external behaviour)
- [x] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)

## Checklist

- [x] My code follows the code style of this project and passes `yarn run lint`.
- [x] My change has adequate [Unit Test coverage](https://publish.obsidian.md/tasks-contributing/Testing/About+Testing).

## Terms

- [x] My contribution follows this project's [contributing guide](https://publish.obsidian.md/tasks-contributing)
- [x] I agree to follow this project's [Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md)



Patch:
diff --git a/src/Suggestor/Suggestor.ts b/src/Suggestor/Suggestor.ts
index 0af3b9b2..2838a190 100644
--- a/src/Suggestor/Suggestor.ts
+++ b/src/Suggestor/Suggestor.ts
@@ -451,9 +451,9 @@ function addDependsOnSuggestions(
         const newTaskToAppend = dependsOnMatch[3];
 
         // Find all Tasks, Already Added
-        let blockingTasks = [] as Task[];
+        let blockingTasks: Task[] = [];
         if (existingDependsOnIdStrings) {
-            blockingTasks = allTasks.filter((task) => task.id && existingDependsOnIdStrings.contains(task.id));
+            blockingTasks = allTasks.filter((task) => task.id && existingDependsOnIdStrings.includes(task.id));
         }
 
         if (newTaskToAppend.length >= settings.autoSuggestMinMatch) {


Test patch:
diff --git a/tests/Obsidian/ObsidianSearchFacilities.test.ts b/tests/Obsidian/ObsidianSearchFacilities.test.ts
new file mode 100644
index 00000000..43b45fc0
--- /dev/null
+++ b/tests/Obsidian/ObsidianSearchFacilities.test.ts
@@ -0,0 +1,122 @@
+/**
+ * @file This file tests our fake implementation of Obsidian's {@link prepareSimpleSearch}.
+ *       Additional fake search functions may be added in the future.
+ */
+
+import { prepareSimpleSearch } from '../__mocks__/obsidian';
+
+function simpleSearchShouldNotMatch(searchTerm: string, phrase: string) {
+    const fn = prepareSimpleSearch(searchTerm);
+    const matches = fn(phrase);
+    expect(matches).toBeNull();
+}
+
+function simpleSearchResultAsJSON(searchTerm: string, phrase: string) {
+    const fn = prepareSimpleSearch(searchTerm);
+    const matches = fn(phrase);
+    return JSON.stringify(matches, null, 4);
+}
+
+// Note: The matches values shown below were obtained by  adding console.log()
+//       calls in searchDescriptionWithoutTags() to inspect the results of Obsidian's own
+//       prepareSimpleSearch(), and then ensuring our fake version gave the same matches values.
+//       No attempt has been made to reproduce the Obsidian-generated score.
+describe('prepareSimpleSearch() fake', () => {
+    it('should provide prepareSimpleSearch() function to do the search', () => {
+        const searchTerm = 'hello';
+        const phrase = 'hello world';
+        expect(simpleSearchResultAsJSON(searchTerm, phrase)).toMatchInlineSnapshot(`
+            "{
+                "score": 0,
+                "matches": [
+                    [
+                        0,
+                        5
+                    ]
+                ]
+            }"
+        `);
+    });
+
+    it('should return null if search term is only spaces', () => {
+        const searchTerm = ' ';
+        const phrase = 'a b c';
+        simpleSearchShouldNotMatch(searchTerm, phrase);
+    });
+
+    it('should return null if no match', () => {
+        const searchTerm = 'NOT PRESENT';
+        const phrase = 'aaaaaaaaaaaaaaaaa';
+        simpleSearchShouldNotMatch(searchTerm, phrase);
+    });
+
+    it('should be case-insensitive', () => {
+        const searchTerm = 'MixedCase';
+        const phrase = 'mixedcase';
+        expect(simpleSearchResultAsJSON(searchTerm, phrase)).toMatchInlineSnapshot(`
+            "{
+                "score": 0,
+                "matches": [
+                    [
+                        0,
+                        9
+                    ]
+                ]
+            }"
+        `);
+    });
+
+    it('should find two occurrences of one string', () => {
+        const searchTerm = 'gues';
+        const phrase = 'Guestimate the number of guests';
+        expect(simpleSearchResultAsJSON(searchTerm, phrase)).toMatchInlineSnapshot(`
+            "{
+                "score": 0,
+                "matches": [
+                    [
+                        0,
+                        4
+                    ],
+                    [
+                        25,
+                        29
+                    ]
+                ]
+            }"
+        `);
+    });
+
+    it('should support search terms with multiple words', () => {
+        const searchTerm = 'make foo';
+        const phrase = 'Make the food - duplicate search words: FOOD MAKE';
+        expect(simpleSearchResultAsJSON(searchTerm, phrase)).toMatchInlineSnapshot(`
+            "{
+                "score": 0,
+                "matches": [
+                    [
+                        0,
+                        4
+                    ],
+                    [
+                        9,
+                        12
+                    ],
+                    [
+                        40,
+                        43
+                    ],
+                    [
+                        45,
+                        49
+                    ]
+                ]
+            }"
+        `);
+    });
+
+    it('should require all words in query to be found', () => {
+        const searchTerm = 'make ZZZ';
+        const phrase = 'make aaaaaaa';
+        simpleSearchShouldNotMatch(searchTerm, phrase);
+    });
+});
diff --git a/tests/Suggestor/Suggestor.test.ts b/tests/Suggestor/Suggestor.test.ts
index b79b46d8..4b1aba20 100644
--- a/tests/Suggestor/Suggestor.test.ts
+++ b/tests/Suggestor/Suggestor.test.ts
@@ -79,6 +79,98 @@ describe.each([
         name === 'dataview',
     );
 
+    /** Build suggestions for the simple case where the cursor is at the very end of the line.
+     */
+    function buildSuggestionsForEndOfLine(line: string, allTasks: Task[] = []) {
+        const originalSettings = getSettings();
+        return buildSuggestions(line, line.length - 1, originalSettings, allTasks);
+    }
+
+    /**
+     * Assert that the suggestions generated from the given line **contain** substrings specified in the
+     * {@link expectedSubstrings} array.
+     *
+     * Each suggestion must contain the corresponding substring from the {@link expectedSubstrings} array.
+     *
+     * @note Additional suggestions are allowed. This only tests the initial suggestions, as defined
+     *       by the number of elements in {@link expectedSubstrings}.
+     *
+     * @param line - The line to generate suggestions from, when the cursor is at the end of the line.
+     * @param expectedSubstrings - The array of substrings that the suggestions should **contain**.
+     * @param allTasks - Optional array of tasks to consider while generating suggestions.
+     *
+     * @returns An array of {@link SuggestInfo} suggestions generated from the line, to allow for additional testing.
+     * @see shouldStartWithSuggestionsEqualling
+     */
+    function shouldStartWithSuggestionsContaining(
+        line: string,
+        expectedSubstrings: string[],
+        allTasks: Task[] = [],
+    ): SuggestInfo[] {
+        const useEqual = false;
+        return shouldStartWithSuggestions(line, expectedSubstrings, useEqual, allTasks);
+    }
+
+    /**
+     * Assert that the suggestions generated from the given line **equal** the strings specified in the
+     * {@link expectedSuggestions} array.
+     *
+     * Each suggestion must equal the corresponding string from the {@link expectedSuggestions} array.
+     *
+     * @note Additional suggestions are allowed. This only tests the initial suggestions, as defined
+     *       by the number of elements in {@link expectedSuggestions}.
+     *
+     * @param line - The line to generate suggestions from, when the cursor is at the end of the line.
+     * @param expectedSuggestions - The array of strings that the suggestions should **equal**.
+     * @param allTasks - Optional array of tasks to consider while generating suggestions.
+     *
+     * @returns An array of {@link SuggestInfo} suggestions generated from the line, to allow for additional testing.
+     * @see shouldStartWithSuggestionsContaining
+     */
+    function shouldStartWithSuggestionsEqualling(line: string, expectedSuggestions: string[], allTasks: Task[] = []) {
+        const useEqual = true;
+        return shouldStartWithSuggestions(line, expectedSuggestions, useEqual, allTasks);
+    }
+
+    /**
+     * @note This is in implementation detail of {@link shouldStartWithSuggestionsContaining}
+     *       and {@link shouldStartWithSuggestionsEqualling}. Use them instead.
+     */
+    function shouldStartWithSuggestions(
+        line: string,
+        expectedSuggestions: string[],
+        useEqual: boolean,
+        allTasks: Task[] = [],
+    ) {
+        // Validate the test itself:
+        expect(expectedSuggestions).not.toHaveLength(0);
+
+        const suggestions = buildSuggestionsForEndOfLine(line, allTasks);
+        expectedSuggestions.forEach((expectedSuggestion, index) => {
+            const displayText = suggestions[index].displayText;
+            if (useEqual) {
+                expect(displayText).toEqual(expectedSuggestion);
+            } else {
+                expect(displayText).toContain(expectedSuggestion);
+            }
+        });
+
+        // return the suggestions, to allow for further validation
+        return suggestions;
+    }
+
+    function shouldOnlyOfferDefaultSuggestions(suggestions: SuggestInfo[]) {
+        if (name === 'emoji') {
+            expect(suggestions[0].displayText).toEqual('⏎');
+        } else if (name === 'dataview') {
+            expect(suggestions[0].displayText).toEqual('due:: due date');
+        } else {
+            // we should never reach here
+            // add a new case above if adding a new format
+            expect(1).toEqual(2);
+        }
+    }
+
     const {
         dueDateSymbol,
         scheduledDateSymbol,
@@ -88,85 +180,60 @@ describe.each([
         idSymbol,
         dependsOnSymbol,
     } = symbols;
+
     it('offers basic completion options for an empty task', () => {
         // Arrange
-        const originalSettings = getSettings();
         const line = '- [ ] ';
-        const suggestions: SuggestInfo[] = buildSuggestions(line, 5, originalSettings, [] as Task[]);
+        const suggestions = buildSuggestionsForEndOfLine(line);
         verifyAsJson(suggestions);
     });
 
     it('offers generic due date completions', () => {
         // Arrange
-        const originalSettings = getSettings();
         const line = `- [ ] some task ${dueDateSymbol}`;
-        const suggestions: SuggestInfo[] = buildSuggestions(line, 17, originalSettings, [] as Task[]);
-        expect(suggestions[0].displayText).toContain('today');
-        expect(suggestions[1].displayText).toContain('tomorrow');
+        const suggestions = shouldStartWithSuggestionsContaining(line, ['today', 'tomorrow']);
         expect(suggestions.length).toEqual(6);
     });
 
     it('offers specific due date completions', () => {
         // Arrange
-        const originalSettings = getSettings();
         const line = `- [ ] some task ${dueDateSymbol} to`;
-        const suggestions: SuggestInfo[] = buildSuggestions(line, 20, originalSettings, [] as Task[]);
-        expect(suggestions[0].displayText).toContain('today');
-        expect(suggestions[1].displayText).toContain('tomorrow');
-    });
-
-    it('offers task suggestions for tasks too possible depend on', () => {
-        // Arrange
-        const originalSettings = getSettings();
-        const line = `- [ ] some task ${dependsOnSymbol} `;
-        const taskToDependOn = TaskBuilder.createFullyPopulatedTask();
-
-        const suggestions: SuggestInfo[] = buildSuggestions(line, line.length - 1, originalSettings, [taskToDependOn]);
-        expect(suggestions[0].displayText).toContain(taskToDependOn.descriptionWithoutTags);
+        shouldStartWithSuggestionsContaining(line, ['today', 'tomorrow']);
     });
 
     it('offers generic recurrence completions', () => {
-        // Arrange
-        const originalSettings = getSettings();
         const line = `- [ ] some task ${recurrenceSymbol}`;
-        const suggestions: SuggestInfo[] = buildSuggestions(line, 17, originalSettings, [] as Task[]);
-        expect(suggestions[0].displayText).toEqual('every');
-        expect(suggestions[1].displayText).toEqual('every day');
-        expect(suggestions[2].displayText).toEqual('every week');
+        shouldStartWithSuggestionsEqualling(line, ['every', 'every day', 'every week']);
     });
 
     it('offers specific recurrence completions', () => {
         // Arrange
-        const originalSettings = getSettings();
         const line = `- [ ] some task ${recurrenceSymbol} every w`;
-        const suggestions: SuggestInfo[] = buildSuggestions(line, 25, originalSettings, [] as Task[]);
-        expect(suggestions[0].displayText).toEqual('every week');
-        expect(suggestions[1].displayText).toEqual('every week on Sunday');
-        expect(suggestions[2].displayText).toEqual('every week on Monday');
+        shouldStartWithSuggestionsEqualling(line, ['every week', 'every week on Sunday', 'every week on Monday']);
     });
 
     it('respects the minimal match setting', () => {
         // Arrange
         const originalSettings = getSettings();
         originalSettings.autoSuggestMinMatch = 2;
+
         let line = `- [ ] some task ${recurrenceSymbol} e`;
-        let suggestions: SuggestInfo[] = buildSuggestions(line, 19, originalSettings, [] as Task[]);
+        let suggestions = buildSuggestions(line, 19, originalSettings, []);
         expect(suggestions.length).toEqual(0);
+
         line = `- [ ] some task ${recurrenceSymbol} ev`;
-        suggestions = buildSuggestions(line, 20, originalSettings, [] as Task[]);
+        suggestions = buildSuggestions(line, 20, originalSettings, []);
         expect(suggestions[0].displayText).toEqual('every');
         expect(suggestions[1].displayText).toEqual('every day');
     });
 
     it('matches created property suggestion when user types "created" but not "today"', () => {
         // Arrange
-        const originalSettings = getSettings();
         let line = '- [ ] some task cr';
-        let suggestions: SuggestInfo[] = buildSuggestions(line, 18, originalSettings, [] as Task[]);
-        expect(suggestions[0].displayText).toEqual(`${createdDateSymbol} created today (2022-07-11)`);
+        shouldStartWithSuggestionsEqualling(line, [`${createdDateSymbol} created today (2022-07-11)`]);
 
         line = '- [ ] some task tod';
-        suggestions = buildSuggestions(line, 19, originalSettings, [] as Task[]);
+        const suggestions = buildSuggestionsForEndOfLine(line);
         if (name === 'emoji') {
             // The first suggestion is new line
             expect(suggestions[0].suggestionType).toEqual('empty');
@@ -181,6 +248,82 @@ describe.each([
         expect(suggestions[0].displayText).not.toContain('created today');
     });
 
+    describe('suggestions for dependency fields', () => {
+        it('should offer "id" then "depends on" if user typed "id"', () => {
+            const line = '- [ ] some task id';
+            shouldStartWithSuggestionsEqualling(line, [`${idSymbol} Task ID`, `${dependsOnSymbol} Task depends on ID`]);
+        });
+
+        it('should offer to generate unique id if the id symbol is already present', () => {
+            const line = `- [ ] some task ${idSymbol}`;
+            shouldStartWithSuggestionsEqualling(line, ['Auto Generate Unique ID']);
+        });
+
+        it('should offer to depend on only task in vault, and include its filename in suggestion if user typed "id"', () => {
+            const line = `- [ ] some task ${dependsOnSymbol} `;
+            const taskToDependOn = TaskBuilder.createFullyPopulatedTask();
+            shouldStartWithSuggestionsEqualling(line, ['Do exercises - From: fileName.md'], [taskToDependOn]);
+        });
+
+        // TODO should not offer to depend on self
+        // TODO should offer tasks in current file before those in other files
+        // TODO should not offer id or dependsOn if cursor is in the middle of the line
+        // TODO test that it uses the same regex for Task IDs as the rest of the code
+        // TODO confirm it does not unnecessarily rewrite tasks that already have an ID
+
+        describe('suggesting additional dependencies', () => {
+            const taskBuilder = new TaskBuilder().path('root/dir 1/dir 2/file-name.md');
+            const allTasks = [
+                // force line break
+                taskBuilder.description('1').id('1234').build(),
+                taskBuilder.description('2').id('5678').build(),
+            ];
+
+            const suggestTask1 = '1 - From: file-name.md';
+            const suggestTask2 = '2 - From: file-name.md';
+
+            it('should suggest all tasks when there is no existing ID after dependsOn', () => {
+                const line = `- [ ] some task ${dependsOnSymbol} `;
+                shouldStartWithSuggestionsEqualling(line, [suggestTask1, suggestTask2], allTasks);
+            });
+
+            it('should offer tasks containing the search string, if given a partial ID', () => {
+                // 1 does not match any of the existing IDs, so is presumed to be a substring to search for.
+                const line = `- [ ] some task ${dependsOnSymbol} 1`;
+                shouldStartWithSuggestionsEqualling(line, [suggestTask1], allTasks);
+            });
+
+            it('should only offer tasks not already depended upon - with 1 existing dependency', () => {
+                const line = `- [ ] some task ${dependsOnSymbol} 1234,`;
+                shouldStartWithSuggestionsEqualling(line, [suggestTask2], allTasks);
+            });
+
+            it.failing('should offer tasks when first existing dependency id has hyphen and underscore', () => {
+                // TODO hyphen and underscore are not currently recognised in this location by the auto-suggest code
+                const line = `- [ ] some task ${dependsOnSymbol} 1_2-3,`;
+                shouldStartWithSuggestionsEqualling(line, [suggestTask1, suggestTask2], allTasks);
+            });
+
+            it('should only offer tasks not already depended upon - with all tasks already depended on', () => {
+                const line = `- [ ] some task ${dependsOnSymbol} 1234,5678,`;
+                const suggestions = buildSuggestionsForEndOfLine(line, allTasks);
+                shouldOnlyOfferDefaultSuggestions(suggestions);
+            });
+
+            it('should not offer any tasks if there is not a comma after existing depends IDs', () => {
+                const line = `- [ ] some task ${dependsOnSymbol} 1234,5678`;
+                const suggestions = buildSuggestionsForEndOfLine(line, allTasks);
+                shouldOnlyOfferDefaultSuggestions(suggestions);
+            });
+
+            it('should not offer tasks if "IDs" are separated by spaces', () => {
+                const line = `- [ ] some task ${dependsOnSymbol} 1234 5678`;
+                const suggestions = buildSuggestionsForEndOfLine(line, allTasks);
+                shouldOnlyOfferDefaultSuggestions(suggestions);
+            });
+        });
+    });
+
     it('show all suggested text', () => {
         const originalSettings = getSettings();
         originalSettings.autoSuggestMaxItems = 200;
@@ -196,7 +339,7 @@ describe.each([
         ];
         const markdownTable = new MarkdownTable(['Searchable Text', 'Text that is added']);
         for (const line of lines) {
-            let suggestions: SuggestInfo[] = buildSuggestions(line, line.length - 1, originalSettings, [] as Task[]);
+            let suggestions = buildSuggestions(line, line.length - 1, originalSettings, []);
             suggestions = maskIDSuggestionForTesting(idSymbol, suggestions);
             for (const suggestion of suggestions) {
                 // The 'new line' replacement adds a trailing space at the end of a line,
@@ -231,43 +374,43 @@ describe('onlySuggestIfBracketOpen', () => {
 
     it('should suggest if cursor at end of line with an open pair', () => {
         const settings = getSettings();
-        let suggestions = buildSuggestions(...cursorPosition('(hello world|'), settings, [] as Task[]);
+        let suggestions = buildSuggestions(...cursorPosition('(hello world|'), settings, []);
         expect(suggestions).not.toEqual(emptySuggestion);
 
-        suggestions = buildSuggestions(...cursorPosition('[hello world|'), settings, [] as Task[]);
+        suggestions = buildSuggestions(...cursorPosition('[hello world|'), settings, []);
         expect(suggestions).not.toEqual(emptySuggestion);
     });
 
     it('should suggest if cursor at end of line with an nested open pairs', () => {
         const settings = getSettings();
-        let suggestions = buildSuggestions(...cursorPosition('(((hello world))|'), settings, [] as Task[]);
+        let suggestions = buildSuggestions(...cursorPosition('(((hello world))|'), settings, []);
         expect(suggestions).not.toEqual(emptySuggestion);
 
-        suggestions = buildSuggestions(...cursorPosition('[[[hello world]]|'), settings, [] as Task[]);
+        suggestions = buildSuggestions(...cursorPosition('[[[hello world]]|'), settings, []);
         expect(suggestions).not.toEqual(emptySuggestion);
     });
 
     it('should suggest if cursor in middle of closed pair', () => {
         const settings = getSettings();
-        let suggestions = buildSuggestions(...cursorPosition('(hello world|)'), settings, [] as Task[]);
+        let suggestions = buildSuggestions(...cursorPosition('(hello world|)'), settings, []);
         expect(suggestions).not.toEqual(emptySuggestion);
 
-        suggestions = buildSuggestions(...cursorPosition('[hello world|]'), settings, [] as Task[]);
+        suggestions = buildSuggestions(...cursorPosition('[hello world|]'), settings, []);
         expect(suggestions).not.toEqual(emptySuggestion);
     });
 
     it('should suggest if there is an opening bracket after many closing brackets', () => {
-        const suggestions = buildSuggestions(...cursorPosition(']]]]]]](hello|'), getSettings(), [] as Task[]);
+        const suggestions = buildSuggestions(...cursorPosition(']]]]]]](hello|'), getSettings(), []);
         expect(suggestions).not.toEqual(emptySuggestion);
     });
 
     it('should not suggest on an empty line', () => {
-        const suggestions = buildSuggestions(...cursorPosition('|'), getSettings(), [] as Task[]);
+        const suggestions = buildSuggestions(...cursorPosition('|'), getSettings(), []);
         expect(suggestions).toEqual(emptySuggestion);
     });
 
     it("should not suggest if there's no open bracket at cursor position", () => {
-        const suggestions = buildSuggestions(...cursorPosition('(hello world)|'), getSettings(), [] as Task[]);
+        const suggestions = buildSuggestions(...cursorPosition('(hello world)|'), getSettings(), []);
         expect(suggestions).toEqual(emptySuggestion);
     });
 });
diff --git a/tests/__mocks__/obsidian.ts b/tests/__mocks__/obsidian.ts
index 22d91406..e4066687 100644
--- a/tests/__mocks__/obsidian.ts
+++ b/tests/__mocks__/obsidian.ts
@@ -68,3 +68,70 @@ export class Notice {
      */
     hide(): void {}
 }
+
+interface SearchResult {
+    score: number;
+    matches: number[][];
+}
+
+/**
+ * An implementation detail of our fake {@link prepareSimpleSearch} - see below.
+ *
+ * See https://docs.obsidian.md/Reference/TypeScript+API/prepareSimpleSearch
+ * @param searchTerm
+ * @param phrase
+ */
+function caseInsensitiveSubstringSearch(searchTerm: string, phrase: string): SearchResult | null {
+    // Don't try and search for empty strings or just spaces:
+    if (!searchTerm.trim()) {
+        return null;
+    }
+
+    // Support multi-word search terms:
+    const searchTerms = searchTerm.split(/\s+/);
+    let matches: number[][] = [];
+
+    for (const term of searchTerms) {
+        const regex = new RegExp(term, 'gi');
+        let match;
+        let termFound = false;
+        while ((match = regex.exec(phrase)) !== null) {
+            matches.push([match.index, match.index + match[0].length]);
+            termFound = true;
+        }
+
+        // We require all search terms to be found.
+        if (!termFound) {
+            return null;
+        }
+    }
+
+    // Sort matches by start index and then by end index
+    matches = matches.sort((a, b) => {
+        if (a[0] === b[0]) {
+            return a[1] - b[1];
+        }
+        return a[0] - b[0];
+    });
+
+    return matches.length > 0
+        ? {
+              score: 0, // this fake implementation does not support calculating scores.
+              matches: matches,
+          }
+        : null;
+}
+
+/**
+ * A fake implementation of prepareSimpleSearch(),
+ * so we can write tests of code that calls that function.
+ * Note that the returned score is always 0.
+ *
+ * See https://docs.obsidian.md/Reference/TypeScript+API/prepareSimpleSearch
+ * @param query - the search term
+ */
+export function prepareSimpleSearch(query: string): (text: string) => SearchResult | null {
+    return function (text: string): SearchResult | null {
+        return caseInsensitiveSubstringSearch(query, text);
+    };
+}