diff --git a/src/libs/DynamicArrayBuffer.ts b/src/libs/DynamicArrayBuffer.ts new file mode 100644 index 000000000000..750922d432a0 --- /dev/null +++ b/src/libs/DynamicArrayBuffer.ts @@ -0,0 +1,99 @@ +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; + } + + [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 index a947867f596c..e7d5ef051960 100644 --- a/src/libs/FastSearch.ts +++ b/src/libs/FastSearch.ts @@ -1,6 +1,7 @@ /* eslint-disable rulesdir/prefer-at */ import CONST from '@src/CONST'; import Timing from './actions/Timing'; +import DynamicArrayBuffer from './DynamicArrayBuffer'; import SuffixUkkonenTree from './SuffixUkkonenTree'; type SearchableData = { @@ -25,6 +26,8 @@ type SearchableData = { // 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. @@ -35,27 +38,30 @@ const charSetToSkip = new Set(['@', '.', '#', '$', '%', '&', '*', '+', '-', '/', */ function createFastSearch(dataSets: Array>) { Timing.start(CONST.TIMING.SEARCH_CONVERT_SEARCH_VALUES); - const maxNumericListSize = 400_000; + 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: - let concatenatedNumericList = new Uint8Array(maxNumericListSize); + 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 Uint32Array(maxNumericListSize * 4); - // As we are working with ArrayBuffers, we need to keep track of the current offset: - const offset = {value: 1}; + 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); + for (const {data, toSearchableString} of dataSets) { // Performance critical: the array parameters are passed by reference, so we don't have to create new arrays every time: - dataToNumericRepresentation(concatenatedNumericList, occurrenceToIndex, offset, {data, toSearchableString}); - listOffsets.push(offset.value); + dataToNumericRepresentation(concatenatedNumericList, occurrenceToIndex, {data, toSearchableString}); + listOffsets.push(concatenatedNumericList.length); } - concatenatedNumericList[offset.value++] = SuffixUkkonenTree.END_CHAR_CODE; - listOffsets[listOffsets.length - 1] = offset.value; + 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 = concatenatedNumericList.slice(0, offset.value); + concatenatedNumericList.truncate(); // Create & build the suffix tree: Timing.start(CONST.TIMING.SEARCH_MAKE_TREE); @@ -84,7 +90,7 @@ function createFastSearch(dataSets: Array>) { // eslint-disable-next-line @typescript-eslint/prefer-for-of for (let i = 0; i < result.length; i++) { const occurrenceIndex = result[i]; - const itemIndexInDataSet = occurrenceToIndex[occurrenceIndex]; + const itemIndexInDataSet = occurrenceToIndex.array[occurrenceIndex]; const dataSetIndex = listOffsets.findIndex((listOffset) => occurrenceIndex < listOffset); if (dataSetIndex === -1) { @@ -128,7 +134,11 @@ function createFastSearch(dataSets: Array>) { * 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(concatenatedNumericList: Uint8Array, occurrenceToIndex: Uint32Array, offset: {value: number}, {data, toSearchableString}: SearchableData): void { +function dataToNumericRepresentation( + concatenatedNumericList: DynamicArrayBuffer, + occurrenceToIndex: DynamicArrayBuffer, + {data, toSearchableString}: SearchableData, +): void { data.forEach((option, index) => { const searchStringForTree = toSearchableString(option); const cleanedSearchStringForTree = cleanString(searchStringForTree); @@ -140,16 +150,13 @@ function dataToNumericRepresentation(concatenatedNumericList: Uint8Array, occ SuffixUkkonenTree.stringToNumeric(cleanedSearchStringForTree, { charSetToSkip, out: { - outArray: concatenatedNumericList, - offset, - outOccurrenceToIndex: occurrenceToIndex, index, + occurrenceToIndex, + array: concatenatedNumericList, }, }); - // eslint-disable-next-line no-param-reassign - occurrenceToIndex[offset.value] = index; - // eslint-disable-next-line no-param-reassign - concatenatedNumericList[offset.value++] = SuffixUkkonenTree.DELIMITER_CHAR_CODE; + occurrenceToIndex.set(concatenatedNumericList.length, index); + concatenatedNumericList.push(SuffixUkkonenTree.DELIMITER_CHAR_CODE); }); } diff --git a/src/libs/SuffixUkkonenTree/index.ts b/src/libs/SuffixUkkonenTree/index.ts index bcefd1008493..d12da078dfa9 100644 --- a/src/libs/SuffixUkkonenTree/index.ts +++ b/src/libs/SuffixUkkonenTree/index.ts @@ -2,6 +2,7 @@ // .at() has a performance overhead we explicitly want to avoid here /* eslint-disable no-continue */ +import type DynamicArrayBuffer from '@libs/DynamicArrayBuffer'; import {ALPHABET_SIZE, DELIMITER_CHAR_CODE, END_CHAR_CODE, SPECIAL_CHAR_CODE, stringToNumeric} from './utils'; /** @@ -20,7 +21,7 @@ import {ALPHABET_SIZE, DELIMITER_CHAR_CODE, END_CHAR_CODE, SPECIAL_CHAR_CODE, st * * The tree will be built using the Ukkonen's algorithm: https://www.cs.helsinki.fi/u/ukkonen/SuffixT1withFigs.pdf */ -function makeTree(numericSearchValues: Uint8Array) { +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. @@ -85,7 +86,7 @@ function makeTree(numericSearchValues: Uint8Array) { currentNode = transitionNodes[currentNode * ALPHABET_SIZE + char]; currentPosition = rangeStart[currentNode]; } - if (currentPosition === 0 || char === numericSearchValues[currentPosition]) { + if (currentPosition === 0 || char === numericSearchValues.array[currentPosition]) { currentPosition++; } else { splitEdge(char); @@ -109,14 +110,14 @@ function makeTree(numericSearchValues: Uint8Array) { rangeEnd[nodeCounter] = currentPosition - 1; parent[nodeCounter] = parent[currentNode]; - transitionNodes[nodeCounter * ALPHABET_SIZE + numericSearchValues[currentPosition]] = 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[rangeStart[nodeCounter]]] = nodeCounter; + transitionNodes[parent[nodeCounter] * ALPHABET_SIZE + numericSearchValues.array[rangeStart[nodeCounter]]] = nodeCounter; nodeCounter += 2; handleDescent(nodeCounter); } @@ -125,7 +126,7 @@ function makeTree(numericSearchValues: Uint8Array) { currentNode = suffixLink[parent[latestNodeIndex - 2]]; currentPosition = rangeStart[latestNodeIndex - 2]; while (currentPosition <= rangeEnd[latestNodeIndex - 2]) { - currentNode = transitionNodes[currentNode * ALPHABET_SIZE + numericSearchValues[currentPosition]]; + currentNode = transitionNodes[currentNode * ALPHABET_SIZE + numericSearchValues.array[currentPosition]]; currentPosition += rangeEnd[currentNode] - rangeStart[currentNode] + 1; } if (currentPosition === rangeEnd[latestNodeIndex - 2] + 1) { @@ -139,7 +140,7 @@ function makeTree(numericSearchValues: Uint8Array) { function build() { initializeTree(); for (currentIndex = 1; currentIndex < numericSearchValues.length; ++currentIndex) { - const c = numericSearchValues[currentIndex]; + const c = numericSearchValues.array[currentIndex]; processCharacter(c); } } @@ -165,7 +166,7 @@ function makeTree(numericSearchValues: Uint8Array) { 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[leftRange + i]) { + if (searchValue[depth + i] !== numericSearchValues.array[leftRange + i]) { return; } } diff --git a/src/libs/SuffixUkkonenTree/utils.ts b/src/libs/SuffixUkkonenTree/utils.ts index 96ee35b15796..ed6b67f37a74 100644 --- a/src/libs/SuffixUkkonenTree/utils.ts +++ b/src/libs/SuffixUkkonenTree/utils.ts @@ -1,5 +1,8 @@ -/* eslint-disable rulesdir/prefer-at */ // .at() has a performance overhead we explicitly want to avoid here +/* eslint-disable rulesdir/prefer-at */ +// .at() has a performance overhead we explicitly want to avoid here + /* eslint-disable no-continue */ +import DynamicArrayBuffer from '@libs/DynamicArrayBuffer'; const CHAR_CODE_A = 'a'.charCodeAt(0); const ALPHABET = 'abcdefghijklmnopqrstuvwxyz'; @@ -57,12 +60,10 @@ function stringToNumeric( charSetToSkip?: Set; // When out is provided, the function will write the result to the provided arrays instead of creating new ones (performance) out?: { - outArray: Uint8Array; - // As outArray is a ArrayBuffer we need to keep track of the current offset - offset: {value: number}; + 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 - outOccurrenceToIndex?: Uint32Array; + occurrenceToIndex?: DynamicArrayBuffer; // The index that will be used in the outOccurrenceToIndex array (this is the index of your original data position) index?: number; }; @@ -70,17 +71,16 @@ function stringToNumeric( clamp?: boolean; }, ): { - numeric: Uint8Array; - occurrenceToIndex: Uint32Array; - offset: {value: number}; + 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?.outArray ?? new Uint8Array(input.length * 6); - const offset = options?.out?.offset ?? {value: 0}; - const occurrenceToIndex = options?.out?.outOccurrenceToIndex ?? new Uint32Array(input.length * 16 * 4); + 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]; @@ -88,27 +88,27 @@ function stringToNumeric( continue; } + const charCode = char.charCodeAt(0); + if (char >= 'a' && char <= 'z') { // char is an alphabet character - occurrenceToIndex[offset.value] = index; - outArray[offset.value++] = char.charCodeAt(0) - CHAR_CODE_A; + occurrenceToIndex.push(index); + outArray.push(charCode - CHAR_CODE_A); } else { - const charCode = input.charCodeAt(i); - occurrenceToIndex[offset.value] = index; - outArray[offset.value++] = SPECIAL_CHAR_CODE; + 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[offset.value] = index; - outArray[offset.value++] = asBase26Numeric[j]; + occurrenceToIndex.push(index); + outArray.push(asBase26Numeric[j]); } } } return { - numeric: options?.clamp ? outArray.slice(0, offset.value) : outArray, + numeric: options?.clamp ? outArray.truncate() : outArray, occurrenceToIndex, - offset, }; } diff --git a/src/types/global.d.ts b/src/types/global.d.ts index cb3cba68cce5..febfdb5d3333 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -39,3 +39,10 @@ interface NodeRequire { // eslint-disable-next-line @typescript-eslint/prefer-function-type, @typescript-eslint/no-explicit-any (id: string): T; } + +// Define ArrayBuffer.transfer as its a relatively new API and not yet present in all environments +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +interface ArrayBuffer { + // Might be defined in browsers, in RN hermes it's not implemented yet + transfer?: (length: number) => ArrayBuffer; +} diff --git a/tests/unit/DynamicArrayBufferTest.ts b/tests/unit/DynamicArrayBufferTest.ts new file mode 100644 index 000000000000..db567b9bd116 --- /dev/null +++ b/tests/unit/DynamicArrayBufferTest.ts @@ -0,0 +1,124 @@ +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 index 42487b716d09..bd56ad6e1ee9 100644 --- a/tests/unit/FastSearchTest.ts +++ b/tests/unit/FastSearchTest.ts @@ -84,8 +84,10 @@ describe('FastSearch', () => { }); it('should work with large random data sets', () => { - const data = Array.from({length: 1000}, () => { - return Array.from({length: Math.floor(Math.random() * 22 + 9)}, () => { + 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(''); diff --git a/tests/unit/SuffixUkkonenTreeTest.ts b/tests/unit/SuffixUkkonenTreeTest.ts index c0c556c16e14..692c2ed08442 100644 --- a/tests/unit/SuffixUkkonenTreeTest.ts +++ b/tests/unit/SuffixUkkonenTreeTest.ts @@ -1,9 +1,10 @@ +import DynamicArrayBuffer from '@libs/DynamicArrayBuffer'; import SuffixUkkonenTree from '@libs/SuffixUkkonenTree/index'; 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[]) { - const numericLists = strings.map((s) => SuffixUkkonenTree.stringToNumeric(s, {clamp: true})); + 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); @@ -13,9 +14,17 @@ describe('SuffixUkkonenTree', () => { [0], ); numericList.push(SuffixUkkonenTree.END_CHAR_CODE); - return Uint8Array.from(numericList); + 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); @@ -60,4 +69,14 @@ describe('SuffixUkkonenTree', () => { // "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', () => { + 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])); + }); });