Repository: oliverschwendener/ueli
Pull request #1168 (2024-08-16 21:31:11)

Extract fuzzy search logic to common
Changes made in preperation for #1157 which uses fuzzy sorting

oliverschwendener: Nice work, thanks!

Patch:
diff --git a/src/common/Core/Search/InternalSearchFilter.ts b/src/common/Core/Search/InternalSearchFilter.ts
new file mode 100644
index 00000000..799b7243
--- /dev/null
+++ b/src/common/Core/Search/InternalSearchFilter.ts
@@ -0,0 +1,4 @@
+import type { SearchResultItem } from "../SearchResultItem";
+import type { SearchOptions } from "./SearchOptions";
+
+export type InternalSearchFilter = (options: SearchOptions) => SearchResultItem[];
diff --git a/src/common/Core/Search/SearchEngineId.ts b/src/common/Core/Search/SearchEngineId.ts
new file mode 100644
index 00000000..f4a6f3a7
--- /dev/null
+++ b/src/common/Core/Search/SearchEngineId.ts
@@ -0,0 +1,1 @@
+export type SearchEngineId = "Fuse.js" | "fuzzysort";
diff --git a/src/common/Core/Search/SearchFilter.ts b/src/common/Core/Search/SearchFilter.ts
new file mode 100644
index 00000000..5de6e96c
--- /dev/null
+++ b/src/common/Core/Search/SearchFilter.ts
@@ -0,0 +1,14 @@
+import type { SearchResultItem } from "../SearchResultItem";
+import { fuseJsSearchFilter } from "./fuseJsSearchFilter";
+import { fuzzySortFilter } from "./fuzzySortFilter";
+import type { InternalSearchFilter } from "./InternalSearchFilter";
+import type { SearchEngineId } from "./SearchEngineId";
+import type { SearchOptions } from "./SearchOptions";
+
+const internalSearchFilters: Record<SearchEngineId, InternalSearchFilter> = {
+    "Fuse.js": (options: SearchOptions) => fuseJsSearchFilter(options),
+    fuzzysort: (options: SearchOptions) => fuzzySortFilter(options),
+};
+
+export const searchFilter = (options: SearchOptions, searchEngineId: SearchEngineId): SearchResultItem[] =>
+    internalSearchFilters[searchEngineId](options);
diff --git a/src/renderer/Core/Search/Helpers/SearchOptions.ts b/src/common/Core/Search/SearchOptions.ts
similarity index 100%
rename from src/renderer/Core/Search/Helpers/SearchOptions.ts
rename to src/common/Core/Search/SearchOptions.ts
diff --git a/src/renderer/Core/Search/Helpers/SearchResultItemFilter.test.ts b/src/common/Core/Search/SearchResultItemFilter.test.ts
similarity index 100%
rename from src/renderer/Core/Search/Helpers/SearchResultItemFilter.test.ts
rename to src/common/Core/Search/SearchResultItemFilter.test.ts
diff --git a/src/renderer/Core/Search/Helpers/SearchResultItemFilter.ts b/src/common/Core/Search/SearchResultItemFilter.ts
similarity index 94%
rename from src/renderer/Core/Search/Helpers/SearchResultItemFilter.ts
rename to src/common/Core/Search/SearchResultItemFilter.ts
index c9ff16ec..ae396570 100644
--- a/src/renderer/Core/Search/Helpers/SearchResultItemFilter.ts
+++ b/src/common/Core/Search/SearchResultItemFilter.ts
@@ -1,4 +1,4 @@
-import type { SearchResultItem } from "@common/Core";
+import type { SearchResultItem } from "../SearchResultItem";
 
 export class SearchResultItemFilter {
     private constructor(private searchResultItems: SearchResultItem[]) {}
diff --git a/src/renderer/Core/Search/Helpers/fuseJsSearchFilter.test.ts b/src/common/Core/Search/fuseJsSearchFilter.test.ts
similarity index 100%
rename from src/renderer/Core/Search/Helpers/fuseJsSearchFilter.test.ts
rename to src/common/Core/Search/fuseJsSearchFilter.test.ts
diff --git a/src/renderer/Core/Search/Helpers/fuseJsSearchFilter.ts b/src/common/Core/Search/fuseJsSearchFilter.ts
similarity index 64%
rename from src/renderer/Core/Search/Helpers/fuseJsSearchFilter.ts
rename to src/common/Core/Search/fuseJsSearchFilter.ts
index 2f1de17b..079e124b 100644
--- a/src/renderer/Core/Search/Helpers/fuseJsSearchFilter.ts
+++ b/src/common/Core/Search/fuseJsSearchFilter.ts
@@ -1,15 +1,16 @@
-import type { SearchResultItem } from "@common/Core";
 import Fuse from "fuse.js";
-import type { SearchFilter } from "./SearchFilter";
-import type { SearchOptions } from "./SearchOptions";
+import type { InternalSearchFilter } from "./InternalSearchFilter";
 import { SearchResultItemFilter } from "./SearchResultItemFilter";
 
-export const fuseJsSearchFilter: SearchFilter = ({
+/**
+ * Internal search filter using the fuse.js library.
+ */
+export const fuseJsSearchFilter: InternalSearchFilter = ({
     searchResultItems,
     fuzziness,
     maxSearchResultItems,
     searchTerm,
-}: SearchOptions): SearchResultItem[] => {
+}) => {
     const result = new Fuse(searchResultItems, {
         keys: ["name"],
         threshold: fuzziness,
diff --git a/src/renderer/Core/Search/Helpers/fuzzySortFilter.ts b/src/common/Core/Search/fuzzySortFilter.ts
similarity index 67%
rename from src/renderer/Core/Search/Helpers/fuzzySortFilter.ts
rename to src/common/Core/Search/fuzzySortFilter.ts
index 0cfd54b1..d3c45b55 100644
--- a/src/renderer/Core/Search/Helpers/fuzzySortFilter.ts
+++ b/src/common/Core/Search/fuzzySortFilter.ts
@@ -1,14 +1,15 @@
-import type { SearchResultItem } from "@common/Core";
 import fuzzysort from "fuzzysort";
-import type { SearchFilter } from "./SearchFilter";
-import type { SearchOptions } from "./SearchOptions";
+import type { InternalSearchFilter } from "./InternalSearchFilter";
 
-export const fuzzySortFilter: SearchFilter = ({
+/**
+ * Internal search filter using the fuzzysort library.
+ */
+export const fuzzySortFilter: InternalSearchFilter = ({
     fuzziness,
     maxSearchResultItems,
     searchResultItems,
     searchTerm,
-}: SearchOptions): SearchResultItem[] => {
+}) => {
     // We need to invert the fuzziness value when using fuzzysort, as here a lower fuzziness value means a more
     // strict search
     const threshold = Math.round((1 - fuzziness) * 10) / 10;
diff --git a/src/common/Core/Search/index.ts b/src/common/Core/Search/index.ts
new file mode 100644
index 00000000..7ad15e0f
--- /dev/null
+++ b/src/common/Core/Search/index.ts
@@ -0,0 +1,3 @@
+export * from "./SearchEngineId";
+export * from "./SearchFilter";
+export * from "./SearchResultItemFilter";
diff --git a/src/renderer/Core/Search/Helpers/SearchFilter.ts b/src/renderer/Core/Search/Helpers/SearchFilter.ts
deleted file mode 100644
index 134015f5..00000000
--- a/src/renderer/Core/Search/Helpers/SearchFilter.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import type { SearchResultItem } from "@common/Core";
-import type { SearchOptions } from "./SearchOptions";
-
-export type SearchFilter = (options: SearchOptions) => SearchResultItem[];
diff --git a/src/renderer/Core/Search/Helpers/getSearchResult.ts b/src/renderer/Core/Search/Helpers/getSearchResult.ts
index 9b1f296b..54bb48dd 100644
--- a/src/renderer/Core/Search/Helpers/getSearchResult.ts
+++ b/src/renderer/Core/Search/Helpers/getSearchResult.ts
@@ -1,9 +1,8 @@
 import type { SearchResultItem } from "@common/Core";
-import type { SearchFilter } from "./SearchFilter";
-import { SearchResultItemFilter } from "./SearchResultItemFilter";
+import { searchFilter, SearchResultItemFilter, type SearchEngineId } from "@common/Core/Search";
 
 export const getSearchResult = ({
-    searchFilter,
+    searchEngineId,
     favoriteSearchResultItemIds,
     excludedSearchResultItemIds,
     instantSearchResultItems,
@@ -12,7 +11,7 @@ export const getSearchResult = ({
     fuzziness,
     maxSearchResultItems,
 }: {
-    searchFilter: SearchFilter;
+    searchEngineId: SearchEngineId;
     favoriteSearchResultItemIds: string[];
     excludedSearchResultItemIds: string[];
     instantSearchResultItems: SearchResultItem[];
@@ -24,12 +23,15 @@ export const getSearchResult = ({
     searchResultItems = SearchResultItemFilter.createFrom(searchResultItems).exclude(excludedSearchResultItemIds).get();
 
     if (searchTerm.length > 0) {
-        const searchFilterItems = searchFilter({
-            searchResultItems,
-            searchTerm: searchTerm.trim(),
-            fuzziness,
-            maxSearchResultItems,
-        });
+        const searchFilterItems = searchFilter(
+            {
+                searchResultItems,
+                searchTerm: searchTerm.trim(),
+                fuzziness,
+                maxSearchResultItems,
+            },
+            searchEngineId,
+        );
 
         return {
             favorites: SearchResultItemFilter.createFrom(searchFilterItems).pick(favoriteSearchResultItemIds).get(),
diff --git a/src/renderer/Core/Search/Helpers/index.ts b/src/renderer/Core/Search/Helpers/index.ts
index 90a53ba3..595c4846 100644
--- a/src/renderer/Core/Search/Helpers/index.ts
+++ b/src/renderer/Core/Search/Helpers/index.ts
@@ -1,2 +1,1 @@
 export * from "./elementIsVisible";
-export * from "./SearchFilter";
diff --git a/src/renderer/Core/Search/SearchViewController.ts b/src/renderer/Core/Search/SearchViewController.ts
index aa57c408..212d9a05 100644
--- a/src/renderer/Core/Search/SearchViewController.ts
+++ b/src/renderer/Core/Search/SearchViewController.ts
@@ -1,9 +1,7 @@
 import { useSetting } from "@Core/Hooks";
 import type { ContextBridge, SearchResultItem, SearchResultItemAction } from "@common/Core";
+import type { SearchEngineId } from "@common/Core/Search";
 import { useRef, useState } from "react";
-import type { SearchFilter } from "./Helpers";
-import { fuseJsSearchFilter } from "./Helpers/fuseJsSearchFilter";
-import { fuzzySortFilter } from "./Helpers/fuzzySortFilter";
 import { getNextSearchResultItemId } from "./Helpers/getNextSearchResultItemId";
 import { getPreviousSearchResultItemId } from "./Helpers/getPreviousSearchResultItemId";
 import { getSearchResult } from "./Helpers/getSearchResult";
@@ -67,16 +65,11 @@ export const useSearchViewController = ({
 
     const { value: fuzziness } = useSetting({ key: "searchEngine.fuzziness", defaultValue: 0.5 });
     const { value: maxSearchResultItems } = useSetting({ key: "searchEngine.maxResultLength", defaultValue: 50 });
-    const { value: searchEngineId } = useSetting({ key: "searchEngine.id", defaultValue: "Fuse.js" });
-
-    const searchFilters: Record<string, SearchFilter> = {
-        "Fuse.js": (options) => fuseJsSearchFilter(options),
-        fuzzysort: (options) => fuzzySortFilter(options),
-    };
+    const { value: searchEngineId } = useSetting<SearchEngineId>({ key: "searchEngine.id", defaultValue: "Fuse.js" });
 
     const search = (searchTerm: string, selectedItemId?: string) => {
         const searchResult = getSearchResult({
-            searchFilter: searchFilters[searchEngineId],
+            searchEngineId,
             excludedSearchResultItemIds,
             favoriteSearchResultItemIds,
             fuzziness,


Test patch:
diff --git a/src/common/Core/Search/SearchFilter.test.ts b/src/common/Core/Search/SearchFilter.test.ts
new file mode 100644
index 00000000..3280fe62
--- /dev/null
+++ b/src/common/Core/Search/SearchFilter.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, test } from "vitest";
+import type { SearchEngineId } from "./SearchEngineId";
+import { searchFilter } from "./SearchFilter";
+
+describe(searchFilter, () => {
+    test.for<SearchEngineId>(["Fuse.js", "fuzzysort"])(
+        "should return an empty list if search result items are empty with %s",
+        (searchEngineId) => {
+            const actual = searchFilter(
+                {
+                    searchResultItems: [],
+                    searchTerm: "search term",
+                    fuzziness: 0.5,
+                    maxSearchResultItems: 10,
+                },
+                searchEngineId,
+            );
+
+            expect(actual).toEqual([]);
+        },
+    );
+});
diff --git a/src/renderer/Core/Search/Helpers/fuzzySortFilter.test.ts b/src/common/Core/Search/fuzzySortFilter.test.ts
similarity index 97%
rename from src/renderer/Core/Search/Helpers/fuzzySortFilter.test.ts
rename to src/common/Core/Search/fuzzySortFilter.test.ts
index 2ede7acd..d76f3353 100644
--- a/src/renderer/Core/Search/Helpers/fuzzySortFilter.test.ts
+++ b/src/common/Core/Search/fuzzySortFilter.test.ts
@@ -1,5 +1,5 @@
-import type { SearchResultItem } from "@common/Core";
 import { describe, expect, it } from "vitest";
+import type { SearchResultItem } from "../SearchResultItem";
 import { fuzzySortFilter } from "./fuzzySortFilter";
 
 describe(fuzzySortFilter, () => {
diff --git a/src/renderer/Core/Search/Helpers/getSearchResult.test.ts b/src/renderer/Core/Search/Helpers/getSearchResult.test.ts
index 408b4ff2..672f5877 100644
--- a/src/renderer/Core/Search/Helpers/getSearchResult.test.ts
+++ b/src/renderer/Core/Search/Helpers/getSearchResult.test.ts
@@ -1,5 +1,5 @@
 import type { SearchResultItem } from "@common/Core";
-import { describe, expect, it, vi } from "vitest";
+import { describe, expect, it } from "vitest";
 import { getSearchResult } from "./getSearchResult";
 
 describe(getSearchResult, () => {
@@ -14,24 +14,19 @@ describe(getSearchResult, () => {
             { id: "item1", name: "Item 1" },
             { id: "item2", name: "Item 2" },
             { id: "item3", name: "Item 3" },
-            { id: "item4", name: "Item 4" },
-            { id: "item5", name: "Item 5" },
+            { id: "invalid4", name: "Invalid 4" },
+            { id: "invalid5", name: "Invalid 5" },
         ];
 
-        const searchFilter = vi.fn().mockReturnValue(<SearchResultItem[]>[
-            { id: "item2", name: "Item 2" },
-            { id: "item3", name: "Item 3" },
-        ]);
-
         const excludedItemIds = ["item1"];
 
         const actual = getSearchResult({
-            searchFilter,
+            searchEngineId: "Fuse.js",
             favoriteSearchResultItemIds: ["item3"],
             excludedSearchResultItemIds: excludedItemIds,
             instantSearchResultItems,
             searchResultItems,
-            searchTerm: "test search term",
+            searchTerm: "item",
             fuzziness: 0.5,
             maxSearchResultItems: 5,
         });
@@ -40,14 +35,6 @@ describe(getSearchResult, () => {
             favorites: [{ id: "item3", name: "Item 3" }],
             searchResults: [{ id: "item2", name: "Item 2" }, ...instantSearchResultItems],
         });
-
-        expect(searchFilter).toHaveBeenCalledOnce();
-        expect(searchFilter).toHaveBeenCalledWith({
-            searchResultItems: searchResultItems.filter((s) => !excludedItemIds.includes(s.id)),
-            searchTerm: "test search term",
-            fuzziness: 0.5,
-            maxSearchResultItems: 5,
-        });
     });
 
     it("should return a list of favorites and other items (sorted alphabetically) if no search term is given", () => {
@@ -58,7 +45,7 @@ describe(getSearchResult, () => {
         const item5 = <SearchResultItem>{ id: "item5", name: "Item 5" };
 
         const actual = getSearchResult({
-            searchFilter: vi.fn(),
+            searchEngineId: "fuzzysort",
             favoriteSearchResultItemIds: ["item2", "item3"],
             excludedSearchResultItemIds: ["item1"],
             instantSearchResultItems: <SearchResultItem[]>[