diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 2c7fd5f046fc..94931116f405 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1527,9 +1527,6 @@ const CONST = { SEARCH_OPTION_LIST_DEBOUNCE_TIME: 300, RESIZE_DEBOUNCE_TIME: 100, UNREAD_UPDATE_DEBOUNCE_TIME: 300, - SEARCH_CONVERT_SEARCH_VALUES: 'search_convert_search_values', - SEARCH_MAKE_TREE: 'search_make_tree', - SEARCH_BUILD_TREE: 'search_build_tree', SEARCH_FILTER_OPTIONS: 'search_filter_options', USE_DEBOUNCED_STATE_DELAY: 300, LIST_SCROLLING_DEBOUNCE_TIME: 200, @@ -1538,10 +1535,8 @@ const CONST = { PLAY_SOUND_MESSAGE_DEBOUNCE_TIME: 500, NOTIFY_NEW_ACTION_DELAY: 700, SKELETON_ANIMATION_SPEED: 3, - SEARCH_OPTIONS_COMPARISON: 'search_options_comparison', SEARCH_MOST_RECENT_OPTIONS: 'search_most_recent_options', DEBOUNCE_HANDLE_SEARCH: 'debounce_handle_search', - FAST_SEARCH_TREE_CREATION: 'fast_search_tree_creation', }, PRIORITY_MODE: { GSD: 'gsd', diff --git a/src/hooks/useFastSearchFromOptions.ts b/src/hooks/useFastSearchFromOptions.ts deleted file mode 100644 index c46c36a1cc5d..000000000000 --- a/src/hooks/useFastSearchFromOptions.ts +++ /dev/null @@ -1,287 +0,0 @@ -import deburr from 'lodash/deburr'; -import {useCallback, useEffect, useRef, useState} from 'react'; -import {InteractionManager} from 'react-native'; -import Timing from '@libs/actions/Timing'; -import FastSearch from '@libs/FastSearch'; -import Log from '@libs/Log'; -import type {Options as OptionsListType, ReportAndPersonalDetailOptions} from '@libs/OptionsListUtils'; -import {filterUserToInvite, isSearchStringMatch} from '@libs/OptionsListUtils'; -import Performance from '@libs/Performance'; -import type {OptionData} from '@libs/ReportUtils'; -import StringUtils from '@libs/StringUtils'; -import CONST from '@src/CONST'; - -type Options = { - includeUserToInvite: boolean; -}; - -const emptyResult = { - personalDetails: [], - recentReports: [], - userToInvite: null, - currentUserOption: undefined, -}; - -const personalDetailToSearchString = (option: OptionData) => { - const displayName = option.participantsList?.[0]?.displayName ?? ''; - return deburr([option.login ?? '', option.login !== displayName ? displayName : ''].join()); -}; - -const recentReportToSearchString = (option: OptionData) => { - const searchStringForTree = [option.text ?? '', option.login ?? '']; - - if (option.isThread) { - searchStringForTree.push(option.alternateText ?? ''); - } else if (option.isChatRoom) { - searchStringForTree.push(option.subtitle ?? ''); - } else if (option.isPolicyExpenseChat) { - searchStringForTree.push(...[option.subtitle ?? '', option.policyName ?? '']); - } - - return deburr(searchStringForTree.join()); -}; - -const getPersonalDetailUniqueId = (option: OptionData) => { - return option.login ? `personalDetail-${option.login}` : undefined; -}; - -const getRecentReportUniqueId = (option: OptionData) => { - return option.reportID ? `recentReport-${option.reportID}` : undefined; -}; - -// You can either use this to search within report and personal details options -function useFastSearchFromOptions( - options: ReportAndPersonalDetailOptions, - config?: {includeUserToInvite: false}, -): {search: (searchInput: string) => ReportAndPersonalDetailOptions; isInitialized: boolean}; -// Or you can use this to include the user invite option. This will require passing all options -function useFastSearchFromOptions( - options: OptionsListType, - config?: {includeUserToInvite: true}, -): { - search: (searchInput: string) => OptionsListType; - isInitialized: boolean; -}; - -/** - * Hook for making options from OptionsListUtils searchable with FastSearch. - * Builds a suffix tree and returns a function to search in it. - * - * @example - * ``` - * const options = OptionsListUtils.getSearchOptions(...); - * const filterOptions = useFastSearchFromOptions(options); - */ -function useFastSearchFromOptions( - options: ReportAndPersonalDetailOptions | OptionsListType, - {includeUserToInvite}: Options = {includeUserToInvite: false}, -): {search: (searchInput: string) => OptionsListType; isInitialized: boolean} { - const [fastSearch, setFastSearch] = useState> | null>(null); - const [isInitialized, setIsInitialized] = useState(false); - const prevOptionsRef = useRef(null); - const prevFastSearchRef = useRef> | null>(null); - - useEffect(() => { - let newFastSearch: ReturnType>; - const prevOptions = prevOptionsRef.current; - if (prevOptions && shallowCompareOptions(prevOptions, options)) { - return; - } - - const actionId = `fast_search_tree_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; - - InteractionManager.runAfterInteractions(() => { - const startTime = Date.now(); - - Performance.markStart(CONST.TIMING.FAST_SEARCH_TREE_CREATION); - Log.info('[CMD_K_DEBUG] FastSearch tree creation started', false, { - actionId, - personalDetailsCount: options.personalDetails.length, - recentReportsCount: options.recentReports.length, - hasExistingTree: !!prevFastSearchRef.current, - timestamp: startTime, - }); - - try { - prevOptionsRef.current = options; - - // Dispose existing tree if present - if (prevFastSearchRef.current) { - const disposeStartTime = Date.now(); - try { - prevFastSearchRef.current.dispose(); - Log.info('[CMD_K_DEBUG] FastSearch tree disposed (reason: recreate)', false, { - actionId, - disposeTime: Date.now() - disposeStartTime, - timestamp: Date.now(), - }); - } catch (error) { - Log.alert('[CMD_K_FREEZE] FastSearch tree disposed (reason: recreate) failed', { - actionId, - error: String(error), - timestamp: Date.now(), - }); - } - } - - newFastSearch = FastSearch.createFastSearch( - [ - { - data: options.personalDetails, - toSearchableString: personalDetailToSearchString, - uniqueId: getPersonalDetailUniqueId, - }, - { - data: options.recentReports, - toSearchableString: recentReportToSearchString, - uniqueId: getRecentReportUniqueId, - }, - ], - - {shouldStoreSearchableStrings: true}, - ); - - setFastSearch(newFastSearch); - prevFastSearchRef.current = newFastSearch; - setIsInitialized(true); - - const endTime = Date.now(); - Performance.markEnd(CONST.TIMING.FAST_SEARCH_TREE_CREATION); - Log.info('[CMD_K_DEBUG] FastSearch tree creation completed', false, { - actionId, - duration: endTime - startTime, - totalItems: options.personalDetails.length + options.recentReports.length, - isInitialized: true, - timestamp: endTime, - }); - } catch (error) { - const endTime = Date.now(); - Performance.markEnd(CONST.TIMING.FAST_SEARCH_TREE_CREATION); - Log.alert('[CMD_K_FREEZE] FastSearch tree creation failed', { - actionId, - error: String(error), - duration: endTime - startTime, - personalDetailsCount: options.personalDetails.length, - recentReportsCount: options.recentReports.length, - timestamp: endTime, - }); - throw error; - } - }); - }, [options]); - - useEffect( - () => () => { - try { - Log.info('[CMD_K_DEBUG] FastSearch tree cleanup (reason: unmount)', false, { - timestamp: Date.now(), - }); - prevFastSearchRef.current?.dispose(); - } catch (error) { - Log.alert('[CMD_K_FREEZE] FastSearch tree cleanup (reason: unmount) failed', { - error: String(error), - timestamp: Date.now(), - }); - } - }, - [], - ); - - const findInSearchTree = useCallback( - (searchInput: string): OptionsListType => { - if (!fastSearch) { - return emptyResult; - } - const deburredInput = deburr(searchInput); - const searchWords = deburredInput.split(/\s+/); - const searchWordsSorted = StringUtils.sortStringArrayByLength(searchWords); - const longestSearchWord = searchWordsSorted.at(searchWordsSorted.length - 1); // longest word is the last element - if (!longestSearchWord) { - return emptyResult; - } - - // The user might have separated words with spaces to do a search such as: "jo d" -> "john doe" - // With the suffix search tree you can only search for one word at a time. Its most efficient to search for the longest word, - // (as this will limit the results the most) and then afterwards run a quick filter on the results to see if the other words are present. - let [personalDetails, recentReports] = fastSearch.search(longestSearchWord); - - if (searchWords.length > 1) { - personalDetails = personalDetails.filter((pd) => { - const id = getPersonalDetailUniqueId(pd); - const searchableString = id ? fastSearch.searchableStringsMap.get(id) : deburr(pd.text); - return isSearchStringMatch(deburredInput, searchableString); - }); - recentReports = recentReports.filter((rr) => { - const id = getRecentReportUniqueId(rr); - const searchableString = id ? fastSearch.searchableStringsMap.get(id) : deburr(rr.text); - return isSearchStringMatch(deburredInput, searchableString); - }); - } - - if (includeUserToInvite && 'currentUserOption' in options) { - const userToInvite = filterUserToInvite( - { - ...options, - personalDetails, - recentReports, - }, - searchInput, - ); - return { - personalDetails, - recentReports, - userToInvite, - currentUserOption: options.currentUserOption, - }; - } - - return { - personalDetails, - recentReports, - userToInvite: null, - currentUserOption: undefined, - }; - }, - [includeUserToInvite, options, fastSearch], - ); - - return {search: findInSearchTree, isInitialized}; -} - -/** - * Compares two ReportAndPersonalDetailOptions objects shallowly. - * @returns true if the options are shallowly equal, false otherwise. - */ -function shallowCompareOptions(prev: ReportAndPersonalDetailOptions, next: ReportAndPersonalDetailOptions): boolean { - if (!prev || !next) { - return false; - } - - // Compare lengths first - if (prev.personalDetails.length !== next.personalDetails.length || prev.recentReports.length !== next.recentReports.length) { - return false; - } - Timing.start(CONST.TIMING.SEARCH_OPTIONS_COMPARISON); - Performance.markStart(CONST.TIMING.SEARCH_OPTIONS_COMPARISON); - - for (let i = 0; i < prev.personalDetails.length; i++) { - if (prev.personalDetails.at(i)?.keyForList !== next.personalDetails.at(i)?.keyForList) { - Timing.end(CONST.TIMING.SEARCH_OPTIONS_COMPARISON); - Performance.markEnd(CONST.TIMING.SEARCH_OPTIONS_COMPARISON); - return false; - } - } - - for (let i = 0; i < prev.recentReports.length; i++) { - if (prev.recentReports.at(i)?.keyForList !== next.recentReports.at(i)?.keyForList) { - Timing.end(CONST.TIMING.SEARCH_OPTIONS_COMPARISON); - Performance.markEnd(CONST.TIMING.SEARCH_OPTIONS_COMPARISON); - return false; - } - } - Timing.end(CONST.TIMING.SEARCH_OPTIONS_COMPARISON); - Performance.markEnd(CONST.TIMING.SEARCH_OPTIONS_COMPARISON); - return true; -} - -export default useFastSearchFromOptions; diff --git a/src/libs/DynamicArrayBuffer.ts b/src/libs/DynamicArrayBuffer.ts deleted file mode 100644 index b4a3d2ff6be8..000000000000 --- a/src/libs/DynamicArrayBuffer.ts +++ /dev/null @@ -1,103 +0,0 @@ -type TypedArray = Int8Array | Uint8Array | Uint8ClampedArray | Int16Array | Uint16Array | Int32Array | Uint32Array | Float32Array | Float64Array; - -type TypedArrayConstructor = { - new (buffer: ArrayBuffer): T; - new (buffer: ArrayBuffer, byteOffset: number, length: number): T; - BYTES_PER_ELEMENT: number; -}; - -/** - * A TypedArray that can grow dynamically (similar to c++ std::vector). - * You still need to provide an initial size. If the array grows beyond the initial size, it will be resized to double the size. - */ -class DynamicArrayBuffer { - private buffer: ArrayBuffer; - - public array: T; - - private size: number; - - private readonly TypedArrayConstructor: TypedArrayConstructor; - - constructor(initialCapacity: number, TypedArrayConstructor: TypedArrayConstructor) { - this.buffer = new ArrayBuffer(initialCapacity * this.getBytesPerElement(TypedArrayConstructor)); - this.array = new TypedArrayConstructor(this.buffer); - this.size = 0; - this.TypedArrayConstructor = TypedArrayConstructor; - } - - private getBytesPerElement(constructor: TypedArrayConstructor): number { - return constructor.BYTES_PER_ELEMENT; - } - - get capacity(): number { - return this.array.length; - } - - get length(): number { - return this.size; - } - - push(value: number): void { - const capacity = this.array.length; // avoid function calls for performance - if (this.size === capacity) { - this.resize(capacity * 2); - } - this.array[this.size++] = value; - } - - private resize(newCapacity: number): void { - if (typeof this.buffer.transfer === 'function') { - this.buffer = this.buffer.transfer(newCapacity * this.getBytesPerElement(this.TypedArrayConstructor)); - this.array = new this.TypedArrayConstructor(this.buffer); - } else { - const newBuffer = new ArrayBuffer(newCapacity * this.getBytesPerElement(this.TypedArrayConstructor)); - const newArray = new this.TypedArrayConstructor(newBuffer); - newArray.set(this.array); - this.buffer = newBuffer; - this.array = newArray; - } - } - - set(index: number, value: number): void { - if (index < 0) { - throw new Error('Index out of bounds'); - } - - // If the index is beyond our current capacity, resize - const capacity = this.array.length; // avoid function calls for performance - while (index >= capacity) { - this.resize(capacity * 2); - } - - this.size = Math.max(this.size, index + 1); - this.array[index] = value; - } - - truncate(end = this.size): DynamicArrayBuffer { - const length = end; - this.buffer = this.buffer.slice(0, length * this.getBytesPerElement(this.TypedArrayConstructor)); - this.array = new this.TypedArrayConstructor(this.buffer); - - this.size = length; - return this; - } - - clear(): void { - this.truncate(0); - } - - [Symbol.iterator](): Iterator { - let index = 0; - return { - next: (): IteratorResult => { - if (index < this.size) { - return {value: this.array[index++], done: false}; - } - return {value: undefined, done: true}; - }, - }; - } -} - -export default DynamicArrayBuffer; diff --git a/src/libs/FastSearch.ts b/src/libs/FastSearch.ts deleted file mode 100644 index a357deeff217..000000000000 --- a/src/libs/FastSearch.ts +++ /dev/null @@ -1,199 +0,0 @@ -/* eslint-disable rulesdir/prefer-at */ -import CONST from '@src/CONST'; -import Timing from './actions/Timing'; -import DynamicArrayBuffer from './DynamicArrayBuffer'; -import SuffixUkkonenTree from './SuffixUkkonenTree'; - -type FastSearchOptions = { - shouldStoreSearchableStrings?: boolean; -}; - -type SearchableData = { - /** - * The data that should be searchable - */ - data: T[]; - /** - * A function that generates a string from a data entry. The string's value is used for searching. - * If you have multiple fields that should be searchable, simply concat them to the string and return it. - */ - toSearchableString: (data: T) => string; - - /** - * Gives the possibility to identify data by a unique attribute. Assume you have two search results with the same text they might be valid - * and represent different data. In this case, you can provide a function that returns a unique identifier for the data. - * If multiple items with the same identifier are found, only the first one will be returned. - * This fixes: https://github.com/Expensify/App/issues/53579 - */ - uniqueId?: (data: T) => string | undefined; -}; - -type SearchableStringsMap = Map; - -// There are certain characters appear very often in our search data (email addresses), which we don't need to search for. -const charSetToSkip = new Set(['@', '.', '#', '$', '%', '&', '*', '+', '-', '/', ':', ';', '<', '=', '>', '?', '_', '~', '!', ' ', ',', '(', ')']); -// For an account with 12k+ personal details the average search value length was ~60 characters. -const averageSearchValueLength = 60; - -/** - * Creates a new "FastSearch" instance. "FastSearch" uses a suffix tree to search for substrings in a list of strings. - * You can provide multiple datasets. The search results will be returned for each dataset. - * - * Note: Creating a FastSearch instance with a lot of data is computationally expensive. You should create an instance once and reuse it. - * Searches will be very fast though, even with a lot of data. - */ -function createFastSearch(dataSets: Array>, options?: FastSearchOptions) { - Timing.start(CONST.TIMING.SEARCH_CONVERT_SEARCH_VALUES); - const itemsCount = dataSets.reduce((acc, {data}) => acc + data.length, 0); - // An approximation of how many chars the final search string will have (if it gets bigger the underlying buffer will resize aromatically, but its best to avoid resizes): - const initialListSize = itemsCount * averageSearchValueLength; - // The user might provide multiple data sets, but internally, the search values will be stored in this one list: - const concatenatedNumericList = new DynamicArrayBuffer(initialListSize, Uint8Array); - // Here we store the index of the data item in the original data list, so we can map the found occurrences back to the original data: - const occurrenceToIndex = new DynamicArrayBuffer(initialListSize, Uint32Array); - // We store the last offset for a dataSet, so we can map the found occurrences to the correct dataSet: - const listOffsets: number[] = []; - - // The tree is 1-indexed, so we need to add a 0 at the beginning: - concatenatedNumericList.push(0); - - const searchableStringsMap: SearchableStringsMap = new Map(); - - for (const dataSet of dataSets) { - // Performance critical: the array parameters are passed by reference, so we don't have to create new arrays every time: - dataToNumericRepresentation(dataSet, concatenatedNumericList, occurrenceToIndex, searchableStringsMap, options); - listOffsets.push(concatenatedNumericList.length); - } - concatenatedNumericList.push(SuffixUkkonenTree.END_CHAR_CODE); - listOffsets[listOffsets.length - 1] = concatenatedNumericList.length; - Timing.end(CONST.TIMING.SEARCH_CONVERT_SEARCH_VALUES); - - // The list might be larger than necessary, so we clamp it to the actual size: - concatenatedNumericList.truncate(); - - // Create & build the suffix tree: - Timing.start(CONST.TIMING.SEARCH_MAKE_TREE); - const tree = SuffixUkkonenTree.makeTree(concatenatedNumericList); - Timing.end(CONST.TIMING.SEARCH_MAKE_TREE); - - Timing.start(CONST.TIMING.SEARCH_BUILD_TREE); - tree.build(); - Timing.end(CONST.TIMING.SEARCH_BUILD_TREE); - - /** - * Searches for the given input and returns results for each dataset. - */ - function search(searchInput: string): T[][] { - const cleanedSearchString = cleanString(searchInput); - const {numeric} = SuffixUkkonenTree.stringToNumeric(cleanedSearchString, { - charSetToSkip, - // stringToNumeric might return a list that is larger than necessary, so we clamp it to the actual size - // (otherwise the search could fail as we include in our search empty array values): - clamp: true, - }); - const result = tree.findSubstring(Array.from(numeric)); - - const resultsByDataSet = Array.from({length: dataSets.length}, () => new Set()); - const uniqueMap: Record> = {}; - // eslint-disable-next-line @typescript-eslint/prefer-for-of - for (let i = 0; i < result.length; i++) { - const occurrenceIndex = result[i]; - const itemIndexInDataSet = occurrenceToIndex.array[occurrenceIndex]; - const dataSetIndex = listOffsets.findIndex((listOffset) => occurrenceIndex < listOffset); - - if (dataSetIndex === -1) { - throw new Error(`[FastSearch] The occurrence index ${occurrenceIndex} is not in any dataset`); - } - const item = dataSets[dataSetIndex].data[itemIndexInDataSet]; - if (!item) { - throw new Error(`[FastSearch] The item with index ${itemIndexInDataSet} in dataset ${dataSetIndex} is not defined`); - } - - // Check for uniqueness eventually - const getUniqueId = dataSets[dataSetIndex].uniqueId; - if (getUniqueId) { - const uniqueId = getUniqueId(item); - if (uniqueId) { - const hasId = uniqueMap[dataSetIndex]?.[uniqueId]; - if (hasId) { - continue; - } - if (!uniqueMap[dataSetIndex]) { - uniqueMap[dataSetIndex] = {}; - } - uniqueMap[dataSetIndex][uniqueId] = item; - } - } - - resultsByDataSet[dataSetIndex].add(item); - } - - return resultsByDataSet.map((set) => Array.from(set)); - } - function dispose(): void { - concatenatedNumericList.clear(); - occurrenceToIndex.clear(); - tree.disposeTree(); - listOffsets.length = 0; - } - - return { - search, - dispose, - searchableStringsMap, - }; -} - -/** - * The suffix tree can only store string like values, and internally stores those as numbers. - * This function converts the user data (which are most likely objects) to a numeric representation. - * Additionally a list of the original data and their index position in the numeric list is created, which is used to map the found occurrences back to the original data. - */ -function dataToNumericRepresentation( - {data, toSearchableString, uniqueId}: SearchableData, - concatenatedNumericList: DynamicArrayBuffer, - occurrenceToIndex: DynamicArrayBuffer, - searchableStringsMap: SearchableStringsMap, - options?: FastSearchOptions, -): void { - data.forEach((option, index) => { - const searchStringForTree = toSearchableString(option); - - if (options?.shouldStoreSearchableStrings) { - const id = uniqueId?.(option); - if (id) { - searchableStringsMap.set(id, searchStringForTree); - } - } - - const cleanedSearchStringForTree = cleanString(searchStringForTree); - - if (cleanedSearchStringForTree.length === 0) { - return; - } - - SuffixUkkonenTree.stringToNumeric(cleanedSearchStringForTree, { - charSetToSkip, - out: { - index, - occurrenceToIndex, - array: concatenatedNumericList, - }, - }); - occurrenceToIndex.set(concatenatedNumericList.length, index); - concatenatedNumericList.push(SuffixUkkonenTree.DELIMITER_CHAR_CODE); - }); -} - -/** - * Everything in the tree is treated as lowercase. - */ -function cleanString(input: string) { - return input.toLowerCase(); -} - -const FastSearch = { - createFastSearch, -}; - -export default FastSearch; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index fb5f5f204586..8ca494a6b2e4 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -1639,7 +1639,7 @@ function getUserToInviteContactOption({ return userToInvite; } -function isValidReport(reportOption: SearchOption, config: GetValidReportsConfig): boolean { +function isValidReport(option: SearchOption, config: GetValidReportsConfig): boolean { const { betas = [], includeMultipleParticipantReports = false, @@ -1659,14 +1659,11 @@ function isValidReport(reportOption: SearchOption, config: GetValidRepor } = config; const topmostReportId = Navigation.getTopmostReportId(); - // eslint-disable-next-line rulesdir/prefer-at - const option = reportOption; - const report = reportOption.item; - const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${option.chatReportID}`]; - const doesReportHaveViolations = shouldDisplayViolationsRBRInLHN(report, transactionViolations); + const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${option.item.chatReportID}`]; + const doesReportHaveViolations = shouldDisplayViolationsRBRInLHN(option.item, transactionViolations); const shouldBeInOptionList = shouldReportBeInOptionList({ - report, + report: option.item, chatReport, currentReportId: topmostReportId, betas, @@ -1689,13 +1686,13 @@ function isValidReport(reportOption: SearchOption, config: GetValidRepor const isMoneyRequestReport = option.isMoneyRequestReport; const isSelfDM = option.isSelfDM; const isChatRoom = option.isChatRoom; - const accountIDs = getParticipantsAccountIDsForDisplay(report); + const accountIDs = getParticipantsAccountIDsForDisplay(option.item); if (excludeNonAdminWorkspaces && !isPolicyAdmin(option.policyID, policies)) { return false; } - if (isPolicyExpenseChat && report?.isOwnPolicyExpenseChat && !includeOwnedWorkspaceChats) { + if (isPolicyExpenseChat && option.isOwnPolicyExpenseChat && !includeOwnedWorkspaceChats) { return false; } // When passing includeP2P false we are trying to hide features from users that are not ready for P2P and limited to expense chats only. @@ -1719,12 +1716,12 @@ function isValidReport(reportOption: SearchOption, config: GetValidRepor return false; } - if (!canUserPerformWriteAction(report) && !includeReadOnly) { + if (!canUserPerformWriteAction(option.item) && !includeReadOnly) { return false; } // In case user needs to add credit bank account, don't allow them to submit an expense from the workspace. - if (includeOwnedWorkspaceChats && hasIOUWaitingOnCurrentUserBankAccount(report)) { + if (includeOwnedWorkspaceChats && hasIOUWaitingOnCurrentUserBankAccount(option.item)) { return false; } @@ -1739,7 +1736,7 @@ function isValidReport(reportOption: SearchOption, config: GetValidRepor option.isPolicyExpenseChat && option.ownerAccountID === currentUserAccountID && includeOwnedWorkspaceChats && !option.private_isArchived; const shouldShowInvoiceRoom = - includeInvoiceRooms && isInvoiceRoom(report) && isPolicyAdmin(option.policyID, policies) && !option.private_isArchived && canSendInvoiceFromWorkspace(report.policyID); + includeInvoiceRooms && isInvoiceRoom(option.item) && isPolicyAdmin(option.policyID, policies) && !option.private_isArchived && canSendInvoiceFromWorkspace(option.policyID); /* Exclude the report option if it doesn't meet any of the following conditions: @@ -1934,10 +1931,7 @@ function getValidOptions( if (includeRecentReports) { // if maxElements is passed, filter the recent reports by searchString and return only most recent reports (@see recentReportsComparator) - const searchTerms = deburr(searchString ?? '') - .toLowerCase() - .split(' ') - .filter((term) => term.length > 0); + const searchTerms = processSearchString(searchString); const filteringFunction = (report: SearchOption) => { let searchText = `${report.text ?? ''}${report.login ?? ''}`; @@ -1950,7 +1944,7 @@ function getValidOptions( searchText += `${report.subtitle ?? ''}${report.policyName ?? ''}`; } searchText = deburr(searchText.toLocaleLowerCase()); - const searchTermsFound = searchTerms.length > 0 ? searchTerms.every((term) => searchText.includes(term)) : true; + const searchTermsFound = searchTerms.every((term) => searchText.includes(term)); if (!searchTermsFound) { return false; @@ -2010,10 +2004,7 @@ function getValidOptions( }; } - const searchTerms = deburr(searchString ?? '') - .toLowerCase() - .split(' ') - .filter((term) => term.length > 0); + const searchTerms = processSearchString(searchString); const filteringFunction = (personalDetail: OptionData) => { if ( !personalDetail?.login || @@ -2030,7 +2021,7 @@ function getValidOptions( } const searchText = deburr(`${personalDetail.text ?? ''} ${personalDetail.login ?? ''}`.toLocaleLowerCase()); - return searchTerms.length > 0 ? searchTerms.every((term) => searchText.includes(term)) : true; + return searchTerms.every((term) => searchText.includes(term)); }; personalDetailsOptions = optionsOrderBy(options.personalDetails, personalDetailsComparator, maxElements, filteringFunction, true); @@ -2711,6 +2702,18 @@ function shallowOptionsListCompare(a: OptionList, b: OptionList): boolean { return true; } +/** + * Process a search string into normalized search terms + * @param searchString - The raw search string to process + * @returns Array of normalized search terms + */ +function processSearchString(searchString: string | undefined): string[] { + return deburr(searchString ?? '') + .toLowerCase() + .split(' ') + .filter((term) => term.length > 0); +} + export { canCreateOptimisticPersonalDetailOption, combineOrderingOfReportsAndPersonalDetails, diff --git a/src/libs/SuffixUkkonenTree/index.ts b/src/libs/SuffixUkkonenTree/index.ts deleted file mode 100644 index 930203e7bdfe..000000000000 --- a/src/libs/SuffixUkkonenTree/index.ts +++ /dev/null @@ -1,218 +0,0 @@ -/* eslint-disable rulesdir/prefer-at */ -// .at() has a performance overhead we explicitly want to avoid here -import type DynamicArrayBuffer from '@libs/DynamicArrayBuffer'; -import {ALPHABET_SIZE, DELIMITER_CHAR_CODE, END_CHAR_CODE, SPECIAL_CHAR_CODE, stringToNumeric} from './utils'; - -/** - * This implements a suffix tree using Ukkonen's algorithm. - * A good visualization to learn about the algorithm can be found here: https://brenden.github.io/ukkonen-animation/ - * A good video explaining Ukkonen's algorithm can be found here: https://www.youtube.com/watch?v=ALEV0Hc5dDk - * Note: This implementation is optimized for performance, not necessarily for readability. - * - * You probably don't want to use this directly, but rather use @libs/FastSearch.ts as a easy to use wrapper around this. - */ - -/** - * Creates a new tree instance that can be used to build a suffix tree and search in it. - * The input is a numeric representation of the search string, which can be created using {@link stringToNumeric}. - * Separate search values must be separated by the {@link DELIMITER_CHAR_CODE}. The search string must end with the {@link END_CHAR_CODE}. - * - * The tree will be built using the Ukkonen's algorithm: https://www.cs.helsinki.fi/u/ukkonen/SuffixT1withFigs.pdf - */ -function makeTree(numericSearchValues: DynamicArrayBuffer) { - // Every leaf represents a suffix. There can't be more than n suffixes. - // Every internal node has to have at least 2 children. So the total size of ukkonen tree is not bigger than 2n - 1. - // + 1 is because an extra character at the beginning to offset the 1-based indexing. - const maxNodes = 2 * numericSearchValues.length + 1; - /* - This array represents all internal nodes in the suffix tree. - When building this tree, we'll be given a character in the string, and we need to be able to lookup in constant time - if there's any edge connected to a node starting with that character. For example, given a tree like this: - - root - / | \ - a b c - - and the next character in our string is 'd', we need to be able do check if any of the edges from the root node - start with the letter 'd', without looping through all the edges. - - To accomplish this, each node gets an array matching the alphabet size. - So you can imagine if our alphabet was just [a,b,c,d], then each node would get an array like [0,0,0,0]. - If we add an edge starting with 'a', then the root node would be [1,0,0,0] - So given an arbitrary letter such as 'd', then we can take the position of that letter in its alphabet (position 3 in our example) - and check whether that index in the array is 0 or 1. If it's a 1, then there's an edge starting with the letter 'd'. - - Note that for efficiency, all nodes are stored in a single flat array. That's how we end up with (maxNodes * alphabet_size). - In the example of a 4-character alphabet, we'd have an array like this: - - root root.left root.right last possible node - / \ / \ / \ / \ - [0,0,0,0, 0,0,0,0, 0,0,0,0, ................. 0,0,0,0] - */ - let transitionNodes = new Uint32Array(maxNodes * ALPHABET_SIZE); - - // Storing the range of the original string that each node represents: - let rangeStart = new Uint32Array(maxNodes); - let rangeEnd = new Uint32Array(maxNodes); - - let parent = new Uint32Array(maxNodes); - let suffixLink = new Uint32Array(maxNodes); - - let currentNode = 1; - let currentPosition = 1; - let nodeCounter = 3; - let currentIndex = 1; - - function initializeTree() { - rangeEnd.fill(numericSearchValues.length); - rangeEnd[1] = 0; - rangeEnd[2] = 0; - suffixLink[1] = 2; - for (let i = 0; i < ALPHABET_SIZE; ++i) { - transitionNodes[ALPHABET_SIZE * 2 + i] = 1; - } - } - - function processCharacter(char: number) { - // eslint-disable-next-line no-constant-condition - while (true) { - if (rangeEnd[currentNode] < currentPosition) { - if (transitionNodes[currentNode * ALPHABET_SIZE + char] === 0) { - createNewLeaf(char); - continue; - } - currentNode = transitionNodes[currentNode * ALPHABET_SIZE + char]; - currentPosition = rangeStart[currentNode]; - } - if (currentPosition === 0 || char === numericSearchValues.array[currentPosition]) { - currentPosition++; - } else { - splitEdge(char); - continue; - } - break; - } - } - - function createNewLeaf(c: number) { - transitionNodes[currentNode * ALPHABET_SIZE + c] = nodeCounter; - rangeStart[nodeCounter] = currentIndex; - parent[nodeCounter++] = currentNode; - currentNode = suffixLink[currentNode]; - - currentPosition = rangeEnd[currentNode] + 1; - } - - function splitEdge(c: number) { - rangeStart[nodeCounter] = rangeStart[currentNode]; - rangeEnd[nodeCounter] = currentPosition - 1; - parent[nodeCounter] = parent[currentNode]; - - transitionNodes[nodeCounter * ALPHABET_SIZE + numericSearchValues.array[currentPosition]] = currentNode; - transitionNodes[nodeCounter * ALPHABET_SIZE + c] = nodeCounter + 1; - rangeStart[nodeCounter + 1] = currentIndex; - parent[nodeCounter + 1] = nodeCounter; - rangeStart[currentNode] = currentPosition; - parent[currentNode] = nodeCounter; - - transitionNodes[parent[nodeCounter] * ALPHABET_SIZE + numericSearchValues.array[rangeStart[nodeCounter]]] = nodeCounter; - nodeCounter += 2; - handleDescent(nodeCounter); - } - - function handleDescent(latestNodeIndex: number) { - currentNode = suffixLink[parent[latestNodeIndex - 2]]; - currentPosition = rangeStart[latestNodeIndex - 2]; - while (currentPosition <= rangeEnd[latestNodeIndex - 2]) { - currentNode = transitionNodes[currentNode * ALPHABET_SIZE + numericSearchValues.array[currentPosition]]; - currentPosition += rangeEnd[currentNode] - rangeStart[currentNode] + 1; - } - if (currentPosition === rangeEnd[latestNodeIndex - 2] + 1) { - suffixLink[latestNodeIndex - 2] = currentNode; - } else { - suffixLink[latestNodeIndex - 2] = latestNodeIndex; - } - currentPosition = rangeEnd[currentNode] - (currentPosition - rangeEnd[latestNodeIndex - 2]) + 2; - } - - function build() { - initializeTree(); - for (currentIndex = 1; currentIndex < numericSearchValues.length; ++currentIndex) { - const c = numericSearchValues.array[currentIndex]; - processCharacter(c); - } - } - function disposeTree() { - rangeEnd = new Uint32Array(0); - rangeStart = new Uint32Array(0); - transitionNodes = new Uint32Array(0); - parent = new Uint32Array(0); - suffixLink = new Uint32Array(0); - } - - /** - * Returns all occurrences of the given (sub)string in the input string. - * - * You can think of the tree that we create as a big string that looks like this: - * - * "banana$pancake$apple|" - * The example delimiter character '$' is used to separate the different strings. - * The end character '|' is used to indicate the end of our search string. - * - * This function will return the index(es) of found occurrences within this big string. - * So, when searching for "an", it would return [1, 3, 8]. - */ - function findSubstring(searchValue: number[]) { - const occurrences: number[] = []; - - function dfs(node: number, depth: number) { - const leftRange = rangeStart[node]; - const rightRange = rangeEnd[node]; - const rangeLen = node === 1 ? 0 : rightRange - leftRange + 1; - - for (let i = 0; i < rangeLen && depth + i < searchValue.length && leftRange + i < numericSearchValues.length; i++) { - if (searchValue[depth + i] !== numericSearchValues.array[leftRange + i]) { - return; - } - } - - let isLeaf = true; - for (let i = 0; i < ALPHABET_SIZE; ++i) { - const tNode = transitionNodes[node * ALPHABET_SIZE + i]; - - // Search speed optimization: don't go through the edge if it's different than the next char: - const correctChar = depth + rangeLen >= searchValue.length || i === searchValue[depth + rangeLen]; - - if (tNode !== 0 && tNode !== 1 && correctChar) { - isLeaf = false; - dfs(tNode, depth + rangeLen); - } - } - - if (isLeaf && depth + rangeLen >= searchValue.length) { - occurrences.push(numericSearchValues.length - (depth + rangeLen) + 1); - } - } - - dfs(1, 0); - return occurrences; - } - - return { - build, - findSubstring, - disposeTree, - }; -} - -const SuffixUkkonenTree = { - makeTree, - - // Re-exported from utils: - DELIMITER_CHAR_CODE, - SPECIAL_CHAR_CODE, - END_CHAR_CODE, - stringToNumeric, -}; - -export default SuffixUkkonenTree; diff --git a/src/libs/SuffixUkkonenTree/utils.ts b/src/libs/SuffixUkkonenTree/utils.ts deleted file mode 100644 index cd3091786608..000000000000 --- a/src/libs/SuffixUkkonenTree/utils.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* eslint-disable rulesdir/prefer-at */ -// .at() has a performance overhead we explicitly want to avoid here -import DynamicArrayBuffer from '@libs/DynamicArrayBuffer'; - -const CHAR_CODE_A = 'a'.charCodeAt(0); -const ALPHABET = 'abcdefghijklmnopqrstuvwxyz'; -const LETTER_ALPHABET_SIZE = ALPHABET.length; -const ALPHABET_SIZE = LETTER_ALPHABET_SIZE + 3; // +3: special char, delimiter char, end char -const SPECIAL_CHAR_CODE = ALPHABET_SIZE - 3; -const DELIMITER_CHAR_CODE = ALPHABET_SIZE - 2; -const END_CHAR_CODE = ALPHABET_SIZE - 1; - -// Store the results for a char code in a lookup table to avoid recalculating the same values (performance optimization) -const base26LookupTable = new Array(); - -/** - * Converts a number to a base26 representation. - */ -function convertToBase26(num: number): number[] { - if (num < 0) { - throw new Error('convertToBase26: Input must be a non-negative integer'); - } - if (base26LookupTable[num]) { - return base26LookupTable[num]; - } - - const result: number[] = []; - let workingNum = num; - - do { - workingNum--; - result.unshift(workingNum % 26); - workingNum = Math.floor(workingNum / 26); - } while (workingNum > 0); - - base26LookupTable[num] = result; - return result; -} - -/** - * Converts a string to an array of numbers representing the characters of the string. - * Every number in the array is in the range [0, ALPHABET_SIZE-1] (0-28). - * - * The numbers are offset by the character code of 'a' (97). - * - This is so that the numbers from a-z are in the range 0-28. - * - 26 is for encoding special characters. Character numbers that are not within the range of a-z will be encoded as "specialCharacter + base26(charCode)" - * - 27 is for the delimiter character - * - 28 is for the end character - * - * Note: The string should be converted to lowercase first (otherwise uppercase letters get base26'ed taking more space than necessary). - */ -function stringToNumeric( - // The string we want to convert to a numeric representation - input: string, - options?: { - // A set of characters that should be skipped and not included in the numeric representation - charSetToSkip?: Set; - // When out is provided, the function will write the result to the provided arrays instead of creating new ones (performance) - out?: { - array: DynamicArrayBuffer; - // A map of to map the found occurrences to the correct data set - // As the search string can be very long for high traffic accounts (500k+), this has to be big enough, thus its a Uint32Array - occurrenceToIndex?: DynamicArrayBuffer; - // The index that will be used in the outOccurrenceToIndex array (this is the index of your original data position) - index?: number; - }; - // By default false. By default the outArray may be larger than necessary. If clamp is set to true the outArray will be clamped to the actual size. - clamp?: boolean; - }, -): { - numeric: DynamicArrayBuffer; - occurrenceToIndex: DynamicArrayBuffer; -} { - // The out array might be longer than our input string length, because we encode special characters as multiple numbers using the base26 encoding. - // * 6 is because the upper limit of encoding any char in UTF-8 to base26 is at max 6 numbers. - const outArray = options?.out?.array ?? new DynamicArrayBuffer(input.length * 6, Uint8Array); - const occurrenceToIndex = options?.out?.occurrenceToIndex ?? new DynamicArrayBuffer(input.length * 16 * 4, Uint32Array); - const index = options?.out?.index ?? 0; - - // eslint-disable-next-line @typescript-eslint/prefer-for-of -- for-i is slightly faster - for (let i = 0; i < input.length; i++) { - const char = input[i]; - - if (options?.charSetToSkip?.has(char)) { - continue; - } - - const charCode = char.charCodeAt(0); - - if (char >= 'a' && char <= 'z') { - // char is an alphabet character - occurrenceToIndex.push(index); - outArray.push(charCode - CHAR_CODE_A); - } else { - occurrenceToIndex.push(index); - outArray.push(SPECIAL_CHAR_CODE); - const asBase26Numeric = convertToBase26(charCode); - // eslint-disable-next-line @typescript-eslint/prefer-for-of - for (let j = 0; j < asBase26Numeric.length; j++) { - occurrenceToIndex.push(index); - outArray.push(asBase26Numeric[j]); - } - } - } - - return { - numeric: options?.clamp ? outArray.truncate() : outArray, - occurrenceToIndex, - }; -} - -export {stringToNumeric, convertToBase26, ALPHABET, ALPHABET_SIZE, SPECIAL_CHAR_CODE, DELIMITER_CHAR_CODE, END_CHAR_CODE}; diff --git a/src/pages/Share/ShareTab.tsx b/src/pages/Share/ShareTab.tsx index ad603f7fd2ff..6f109af7685c 100644 --- a/src/pages/Share/ShareTab.tsx +++ b/src/pages/Share/ShareTab.tsx @@ -4,7 +4,6 @@ import SelectionList from '@components/SelectionList'; import InviteMemberListItem from '@components/SelectionList/InviteMemberListItem'; import type {SelectionListHandle} from '@components/SelectionList/types'; import useDebouncedState from '@hooks/useDebouncedState'; -import useFastSearchFromOptions from '@hooks/useFastSearchFromOptions'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -55,27 +54,24 @@ function ShareTab(_: unknown, ref: React.Ref) { if (!areOptionsInitialized) { return defaultListOptions; } - return getSearchOptions(options, betas ?? [], false, false); - }, [areOptionsInitialized, betas, options]); - - const {search: filterOptions} = useFastSearchFromOptions(searchOptions, {includeUserToInvite: true}); + return getSearchOptions(options, betas ?? [], false, false, textInputValue, 20, true); + }, [areOptionsInitialized, betas, options, textInputValue]); const recentReportsOptions = useMemo(() => { if (textInputValue.trim() === '') { return optionsOrderBy(searchOptions.recentReports, recentReportComparator, 20); } - const filteredOptions = filterOptions(textInputValue); - const orderedOptions = combineOrderingOfReportsAndPersonalDetails(filteredOptions, textInputValue, { + const orderedOptions = combineOrderingOfReportsAndPersonalDetails(searchOptions, textInputValue, { sortByReportTypeInSearch: true, preferChatRoomsOverThreads: true, }); const reportOptions: OptionData[] = [...orderedOptions.recentReports, ...orderedOptions.personalDetails]; - if (filteredOptions.userToInvite) { - reportOptions.push(filteredOptions.userToInvite); + if (searchOptions.userToInvite) { + reportOptions.push(searchOptions.userToInvite); } return reportOptions.slice(0, 20); - }, [filterOptions, searchOptions.recentReports, textInputValue]); + }, [searchOptions, textInputValue]); useEffect(() => { searchInServer(debouncedTextInputValue.trim()); diff --git a/tests/unit/DynamicArrayBufferTest.ts b/tests/unit/DynamicArrayBufferTest.ts deleted file mode 100644 index db567b9bd116..000000000000 --- a/tests/unit/DynamicArrayBufferTest.ts +++ /dev/null @@ -1,124 +0,0 @@ -import DynamicArrayBuffer from '@libs/DynamicArrayBuffer'; - -describe('DynamicArrayBuffer', () => { - describe('basic operations', () => { - let buffer: DynamicArrayBuffer; - - beforeEach(() => { - buffer = new DynamicArrayBuffer(4, Float64Array); - }); - - test('initial state', () => { - expect(buffer.length).toBe(0); - expect(buffer.capacity).toBe(4); - }); - - test('push operation', () => { - buffer.push(1.1); - expect(buffer.length).toBe(1); - expect(buffer.array[0]).toBe(1.1); - }); - - test('automatic resize', () => { - // Fill initial capacity - buffer.push(1.1); - buffer.push(2.2); - buffer.push(3.3); - buffer.push(4.4); - expect(buffer.capacity).toBe(4); - - // Trigger resize - buffer.push(5.5); - expect(buffer.capacity).toBe(8); - expect(buffer.length).toBe(5); - expect(buffer.array[4]).toBe(5.5); - }); - - test('array access', () => { - buffer.push(1.1); - buffer.push(2.2); - - expect(buffer.array[0]).toBe(1.1); - buffer.array[0] = 3.3; - expect(buffer.array[0]).toBe(3.3); - }); - }); - - describe('truncate operation', () => { - test('truncate reduces capacity to actual size', () => { - const buffer = new DynamicArrayBuffer(8, Float64Array); - buffer.push(1.1); - buffer.push(2.2); - - expect(buffer.capacity).toBe(8); - expect(buffer.length).toBe(2); - buffer.truncate(); - expect(buffer.capacity).toBe(2); - expect(buffer.length).toBe(2); - expect(buffer.array[0]).toBe(1.1); - expect(buffer.array[1]).toBe(2.2); - }); - }); - - describe('iteration', () => { - test('supports Array.from', () => { - const buffer = new DynamicArrayBuffer(4, Float64Array); - buffer.push(1.1); - buffer.push(2.2); - buffer.push(3.3); - - const array = Array.from(buffer); - expect(array).toEqual([1.1, 2.2, 3.3]); - }); - - test('supports for...of loop', () => { - const buffer = new DynamicArrayBuffer(4, Float64Array); - buffer.push(1.1); - buffer.push(2.2); - - const values: number[] = []; - for (const value of buffer) { - values.push(value); - } - expect(values).toEqual([1.1, 2.2]); - }); - - test('supports spread operator', () => { - const buffer = new DynamicArrayBuffer(4, Float64Array); - buffer.push(1.1); - buffer.push(2.2); - - const array = [...buffer]; - expect(array).toEqual([1.1, 2.2]); - }); - - test('supports destructuring', () => { - const buffer = new DynamicArrayBuffer(4, Float64Array); - buffer.push(1.1); - buffer.push(2.2); - buffer.push(3.3); - - const [first, second] = buffer; - expect(first).toBe(1.1); - expect(second).toBe(2.2); - }); - }); - - describe('different TypedArray types', () => { - test('works with Int32Array', () => { - const buffer = new DynamicArrayBuffer(4, Int32Array); - buffer.push(1); - buffer.push(2); - expect(buffer.array[0]).toBe(1); - expect(buffer.array[1]).toBe(2); - }); - - test('works with Uint8Array', () => { - const buffer = new DynamicArrayBuffer(4, Uint8Array); - buffer.push(255); - buffer.push(0); - expect(buffer.array[0]).toBe(255); - expect(buffer.array[1]).toBe(0); - }); - }); -}); diff --git a/tests/unit/FastSearchTest.ts b/tests/unit/FastSearchTest.ts deleted file mode 100644 index bd56ad6e1ee9..000000000000 --- a/tests/unit/FastSearchTest.ts +++ /dev/null @@ -1,171 +0,0 @@ -import FastSearch from '../../src/libs/FastSearch'; - -describe('FastSearch', () => { - it('should insert, and find the word', () => { - const {search} = FastSearch.createFastSearch([ - { - data: ['banana'], - toSearchableString: (data) => data, - }, - ]); - expect(search('an')).toEqual([['banana']]); - }); - - it('should work with multiple words', () => { - const {search} = FastSearch.createFastSearch([ - { - data: ['banana', 'test'], - toSearchableString: (data) => data, - }, - ]); - - expect(search('es')).toEqual([['test']]); - }); - - it('should work when providing two data sets', () => { - const {search} = FastSearch.createFastSearch([ - { - data: ['erica', 'banana'], - toSearchableString: (data) => data, - }, - { - data: ['banana', 'test'], - toSearchableString: (data) => data, - }, - ]); - - expect(search('es')).toEqual([[], ['test']]); - }); - - it('should work with numbers', () => { - const {search} = FastSearch.createFastSearch([ - { - data: [1, 2, 3, 4, 5], - toSearchableString: (data) => String(data), - }, - ]); - - expect(search('2')).toEqual([[2]]); - }); - - it('should work with unicodes', () => { - const {search} = FastSearch.createFastSearch([ - { - data: ['banana', 'ñèşťǒř', 'test'], - toSearchableString: (data) => data, - }, - ]); - - expect(search('èşť')).toEqual([['ñèşťǒř']]); - }); - - it('should work with words containing "reserved special characters"', () => { - const {search} = FastSearch.createFastSearch([ - { - data: ['ba|nana', 'te{st', 'he}llo'], - toSearchableString: (data) => data, - }, - ]); - - expect(search('st')).toEqual([['te{st']]); - expect(search('llo')).toEqual([['he}llo']]); - expect(search('nana')).toEqual([['ba|nana']]); - }); - - it('should be case insensitive', () => { - const {search} = FastSearch.createFastSearch([ - { - data: ['banana', 'TeSt', 'TEST', 'X'], - toSearchableString: (data) => data, - }, - ]); - - expect(search('test')).toEqual([['TeSt', 'TEST']]); - }); - - it('should work with large random data sets', () => { - const data = Array.from({length: 1_000}, () => { - // We generate very large search strings that breaks the assumption of a certain average search value length. - // This will cause a resizing of the underlying buffer, which we want to test here as well. - return Array.from({length: Math.floor(Math.random() * 100 + 9)}, () => { - const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789@-_.'; - return alphabet.charAt(Math.floor(Math.random() * alphabet.length)); - }).join(''); - }); - - const {search} = FastSearch.createFastSearch([ - { - data, - toSearchableString: (x) => x, - }, - ]); - - data.forEach((word) => { - expect(search(word)).toEqual([expect.arrayContaining([word])]); - }); - }); - - it('should find email addresses without dots', () => { - const {search} = FastSearch.createFastSearch([ - { - data: ['test.user@example.com', 'unrelated'], - toSearchableString: (data) => data, - }, - ]); - - expect(search('testuser')).toEqual([['test.user@example.com']]); - expect(search('test.user')).toEqual([['test.user@example.com']]); - expect(search('examplecom')).toEqual([['test.user@example.com']]); - }); - - it('should filter duplicate IDs', () => { - const {search} = FastSearch.createFastSearch([ - { - data: [ - { - text: 'qa.guide@team.expensify.com', - alternateText: 'qa.guide@team.expensify.com', - keyForList: '14365522', - isSelected: false, - isDisabled: false, - accountID: 14365522, - login: 'qa.guide@team.expensify.com', - icons: [ - { - source: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_11.png', - type: 'avatar', - name: 'qa.guide@team.expensify.com', - id: 14365522, - }, - ], - reportID: '', - }, - { - text: 'qa.guide@team.expensify.com', - alternateText: 'qa.guide@team.expensify.com', - keyForList: '714749267', - isSelected: false, - isDisabled: false, - accountID: 714749267, - login: 'qa.guide@team.expensify.com', - icons: [ - { - source: 'ƒ SvgFallbackAvatar(props)', - type: 'avatar', - name: 'qa.guide@team.expensify.com', - id: 714749267, - }, - ], - reportID: '', - }, - ], - toSearchableString: (data) => data.text, - uniqueId: (data) => data.login, - }, - ]); - - const [result] = search('qa.g'); - // The both items are represented using the same string. - expect(result).toHaveLength(1); - }); -}); diff --git a/tests/unit/SuffixUkkonenTreeTest.ts b/tests/unit/SuffixUkkonenTreeTest.ts deleted file mode 100644 index 64cc759d91c0..000000000000 --- a/tests/unit/SuffixUkkonenTreeTest.ts +++ /dev/null @@ -1,104 +0,0 @@ -import DynamicArrayBuffer from '@libs/DynamicArrayBuffer'; -import SuffixUkkonenTree from '@libs/SuffixUkkonenTree/index'; -import {convertToBase26} from '@libs/SuffixUkkonenTree/utils'; - -describe('SuffixUkkonenTree', () => { - // The suffix tree doesn't take strings, but expects an array buffer, where strings have been separated by a delimiter. - function helperStringsToNumericForTree(strings: string[], charSetToSkip?: Set): DynamicArrayBuffer { - const numericLists = strings.map((s) => SuffixUkkonenTree.stringToNumeric(s, {clamp: true, charSetToSkip})); - const numericList = numericLists.reduce( - (acc, {numeric}) => { - acc.push(...numeric, SuffixUkkonenTree.DELIMITER_CHAR_CODE); - return acc; - }, - // The value we pass to makeTree needs to be offset by one - [0], - ); - numericList.push(SuffixUkkonenTree.END_CHAR_CODE); - const arrayBuffer = new DynamicArrayBuffer(numericList.length, Uint8Array); - numericList.forEach((n) => arrayBuffer.push(n)); - return arrayBuffer; - } - - it('should build strings correctly', () => { - const string = 'abc'; - const numeric = SuffixUkkonenTree.stringToNumeric(string, {clamp: true}).numeric; - expect(Array.from(numeric)).toEqual(expect.arrayContaining([0, 1, 2])); - }); - - it('should insert, build, and find all occurrences', () => { - const strings = ['banana', 'pancake']; - const numericIntArray = helperStringsToNumericForTree(strings); - - const tree = SuffixUkkonenTree.makeTree(numericIntArray); - tree.build(); - const searchValue = SuffixUkkonenTree.stringToNumeric('an', {clamp: true}).numeric; - expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([2, 4, 9])); - }); - - it('should find by first character', () => { - const strings = ['pancake', 'banana']; - const numericIntArray = helperStringsToNumericForTree(strings); - const tree = SuffixUkkonenTree.makeTree(numericIntArray); - tree.build(); - const searchValue = SuffixUkkonenTree.stringToNumeric('p', {clamp: true}).numeric; - expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([1])); - }); - - it('should handle identical words', () => { - const strings = ['banana', 'banana', 'x']; - const numericIntArray = helperStringsToNumericForTree(strings); - const tree = SuffixUkkonenTree.makeTree(numericIntArray); - tree.build(); - const searchValue = SuffixUkkonenTree.stringToNumeric('an', {clamp: true}).numeric; - expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([2, 4, 9, 11])); - }); - - it('should convert string to numeric with a list of chars to skip', () => { - const {numeric} = SuffixUkkonenTree.stringToNumeric('abc abc', { - charSetToSkip: new Set(['b', ' ']), - clamp: true, - }); - expect(Array.from(numeric)).toEqual([0, 2, 0, 2]); - }); - - it('should convert string outside of a-z to numeric with clamping', () => { - const {numeric} = SuffixUkkonenTree.stringToNumeric('2', { - clamp: true, - }); - - // "2" in ASCII is 50, so base26(50) = [0, 23] - expect(Array.from(numeric)).toEqual([SuffixUkkonenTree.SPECIAL_CHAR_CODE, 0, 23]); - }); - - it('should find words that contain chars to skip', () => { - // cspell:disable-next-line - const strings = ['b.an.ana', 'panca.ke']; - const numericIntArray = helperStringsToNumericForTree(strings, new Set(['.'])); - const tree = SuffixUkkonenTree.makeTree(numericIntArray); - tree.build(); - - const searchValue = SuffixUkkonenTree.stringToNumeric('an', {clamp: true}).numeric; - expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([2, 4, 9])); - }); -}); - -describe('convertToBase26', () => { - it('should correctly convert small numbers to base-26', () => { - expect(convertToBase26(1)).toEqual([0]); // A - expect(convertToBase26(26)).toEqual([25]); // Z - expect(convertToBase26(27)).toEqual([0, 0]); // AA - }); - - it('should correctly convert numbers around 26 and 32', () => { - // Numbers where division by 26 and 32 behave differently - expect(convertToBase26(52)).toEqual([0, 25]); // AZ - expect(convertToBase26(53)).toEqual([1, 0]); // BA - expect(convertToBase26(57)).toEqual([1, 4]); // BE - expect(convertToBase26(63)).toEqual([1, 10]); // BK - }); - - it('should throw an error on negative input', () => { - expect(() => convertToBase26(-1)).toThrow('convertToBase26: Input must be a non-negative integer'); - }); -}); diff --git a/tests/unit/useFastSearchFromOptions.tsx b/tests/unit/useFastSearchFromOptions.tsx deleted file mode 100644 index 5b03df813775..000000000000 --- a/tests/unit/useFastSearchFromOptions.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import {renderHook} from '@testing-library/react-native'; -import useFastSearchFromOptions from '@hooks/useFastSearchFromOptions'; -import type {Options} from '@libs/OptionsListUtils'; - -const ahmedPersonalDetail = { - login: 'ahmed@example.com', - text: 'Ahmed Gaber', - participantsList: [ - { - displayName: 'Ahmed Gaber', - }, - ], -}; - -const ahmedReport = { - reportID: '1', - text: 'Ahmed Gaber (Report)', -}; - -const fabioPersonalDetail = { - login: 'fabio.john@example.com', - text: 'Fábio John', - participantsList: [ - { - displayName: 'Fábio John', - }, - ], -}; - -const fabioReport = { - reportID: '4', - text: 'Fábio, John (Report)', -}; - -const options = { - currentUserOption: null, - userToInvite: null, - personalDetails: [ - ahmedPersonalDetail, - { - login: 'banana@example.com', - text: 'Banana', - participantsList: [ - { - displayName: 'Banana', - }, - ], - }, - ], - recentReports: [ - ahmedReport, - { - reportID: '2', - text: 'Something else', - }, - { - reportID: '3', - // This starts with Ah as well, but should not match - text: 'Aha', - }, - ], -} as Options; - -const nonLatinOptions = { - currentUserOption: null, - userToInvite: null, - personalDetails: [fabioPersonalDetail], - recentReports: [fabioReport], -} as Options; - -describe('useFastSearchFromOptions', () => { - it('should return sub word matches', () => { - const {result} = renderHook(() => useFastSearchFromOptions(options)); - const {search} = result.current; - - const {personalDetails, recentReports} = search('Ah Ga'); - - expect(personalDetails).toEqual([ahmedPersonalDetail]); - expect(recentReports).toEqual([ahmedReport]); - }); - it('should return reports/personalDetails with non-latin characters', () => { - const {result} = renderHook(() => useFastSearchFromOptions(nonLatinOptions)); - const {search} = result.current; - - const {personalDetails, recentReports} = search('Fabio'); - - expect(personalDetails).toEqual([fabioPersonalDetail]); - expect(recentReports).toEqual([fabioReport]); - }); - it('should return reports/personalDetails with multiple word query and non-latin character', () => { - const {result} = renderHook(() => useFastSearchFromOptions(nonLatinOptions)); - const {search} = result.current; - - const {recentReports, personalDetails} = search('John Fabio'); - - expect(personalDetails).toEqual([fabioPersonalDetail]); - expect(recentReports).toEqual([fabioReport]); - }); -});