diff --git a/CHANGELOG.md b/CHANGELOG.md index a8436d09fb..79722d3f84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added + +- Added a new function: XLOOKUP. [#1458](https://github.com/handsontable/hyperformula/issues/1458) + ### Changed - **Breaking change**: Changed ES module build to use `mjs` files and `exports` property in `package.json` to make importing language files possible in Node environment. [#1344](https://github.com/handsontable/hyperformula/issues/1344) diff --git a/docs/guide/built-in-functions.md b/docs/guide/built-in-functions.md index 1ed8e18fb5..73f9cb2432 100644 --- a/docs/guide/built-in-functions.md +++ b/docs/guide/built-in-functions.md @@ -212,21 +212,22 @@ Total number of functions: **{{ $page.functionsCount }}** ### Lookup and reference -| Function ID | Description | Syntax | -|:------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------------------------------------------------| -| ADDRESS | Returns a cell reference as a string. | ADDRESS(Row, Column[, AbsoluteRelativeMode[, UseA1Notation[, Sheet]]]) | -| CHOOSE | Uses an index to return a value from a list of values. | CHOOSE(Index, Value1, Value2, ...ValueN) | -| COLUMN | Returns column number of a given reference or formula reference if argument not provided. | COLUMNS([Reference]) | -| COLUMNS | Returns the number of columns in the given reference. | COLUMNS(Array) | -| FORMULATEXT | Returns a formula in a given cell as a string. | FORMULATEXT(Reference) | -| HLOOKUP | Searches horizontally with reference to adjacent cells to the bottom. | HLOOKUP(Search_Criterion, Array, Index, Sort_Order) | -| HYPERLINK | Stores the url in the cell's metadata. It can be read using method [`getCellHyperlink`](../api/classes/hyperformula.md#getcellhyperlink) | HYPERLINK(Url[, LinkLabel]) | -| INDEX | Returns the contents of a cell specified by row and column number. The column number is optional and defaults to 1. | INDEX(Range, Row [, Column]) | -| MATCH | Returns the relative position of an item in an array that matches a specified value. | MATCH(Searchcriterion, Lookuparray [, MatchType]) | -| OFFSET | Returns the value of a cell offset by a certain number of rows and columns from a given reference point. | OFFSET(Reference, Rows, Columns, Height, Width) | -| ROW | Returns row number of a given reference or formula reference if argument not provided. | ROW([Reference]) | -| ROWS | Returns the number of rows in the given reference. | ROWS(Array) | -| VLOOKUP | Searches vertically with reference to adjacent cells to the right. | VLOOKUP(Search_Criterion, Array, Index, Sort_Order) | +| Function ID | Description | Syntax | +|:------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------|:----------------------------------------------------------------------------------------| +| ADDRESS | Returns a cell reference as a string. | ADDRESS(Row, Column[, AbsoluteRelativeMode[, UseA1Notation[, Sheet]]]) | +| CHOOSE | Uses an index to return a value from a list of values. | CHOOSE(Index, Value1, Value2, ...ValueN) | +| COLUMN | Returns column number of a given reference or formula reference if argument not provided. | COLUMNS([Reference]) | +| COLUMNS | Returns the number of columns in the given reference. | COLUMNS(Array) | +| FORMULATEXT | Returns a formula in a given cell as a string. | FORMULATEXT(Reference) | +| HLOOKUP | Searches horizontally with reference to adjacent cells to the bottom. | HLOOKUP(Search_Criterion, Array, Index, Sort_Order) | +| HYPERLINK | Stores the url in the cell's metadata. It can be read using method [`getCellHyperlink`](../api/classes/hyperformula.md#getcellhyperlink) | HYPERLINK(Url[, LinkLabel]) | +| INDEX | Returns the contents of a cell specified by row and column number. The column number is optional and defaults to 1. | INDEX(Range, Row [, Column]) | +| MATCH | Returns the relative position of an item in an array that matches a specified value. | MATCH(Searchcriterion, LookupArray [, MatchType]) | +| OFFSET | Returns the value of a cell offset by a certain number of rows and columns from a given reference point. | OFFSET(Reference, Rows, Columns, Height, Width) | +| ROW | Returns row number of a given reference or formula reference if argument not provided. | ROW([Reference]) | +| ROWS | Returns the number of rows in the given reference. | ROWS(Array) | +| VLOOKUP | Searches vertically with reference to adjacent cells to the right. | VLOOKUP(Search_Criterion, Array, Index, Sort_Order) | +| XLOOKUP | Searches for a key in a range and returns the item corresponding to the match it finds. If no match exists, then XLOOKUP can return the closest (approximate) match. | XLOOKUP(LookupValue, LookupArray, ReturnArray, [IfNotFound], [MatchMode], [SearchMode]) | ### Math and trigonometry diff --git a/package.json b/package.json index 9640b05203..06f85405a2 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "test": "npm-run-all lint test:unit test:browser", "test:unit": "cross-env NODE_ICU_DATA=node_modules/full-icu jest", "test:watch": "cross-env NODE_ICU_DATA=node_modules/full-icu jest --watch", + "test:watch-tmp": "cross-env NODE_ICU_DATA=node_modules/full-icu jest --watch xlookup", "test:coverage": "npm run test:unit -- --coverage", "test:logMemory": "cross-env NODE_ICU_DATA=node_modules/full-icu jest --runInBand --logHeapUsage", "test:unit.ci": "cross-env NODE_ICU_DATA=node_modules/full-icu node --expose-gc ./node_modules/jest/bin/jest --forceExit", diff --git a/src/DependencyGraph/TopSort.ts b/src/DependencyGraph/TopSort.ts index c8df450fe4..c04bcde473 100644 --- a/src/DependencyGraph/TopSort.ts +++ b/src/DependencyGraph/TopSort.ts @@ -39,7 +39,7 @@ export class TopSort { * Returns vertices in order of topological sort, but vertices that are on cycles are kept separate. * * @param modifiedNodes - seed for computation. During engine init run, all of the vertices of grap. In recomputation run, changed vertices. - * @param operatingFunction - recomputes value of a node, and returns whether a change occured + * @param operatingFunction - recomputes value of a node, and returns whether a change occurred * @param onCycle - action to be performed when node is on cycle */ public getTopSortedWithSccSubgraphFrom( diff --git a/src/Lookup/AdvancedFind.ts b/src/Lookup/AdvancedFind.ts index 50fe3b5fdb..f855cd224c 100644 --- a/src/Lookup/AdvancedFind.ts +++ b/src/Lookup/AdvancedFind.ts @@ -11,9 +11,11 @@ import { RawNoErrorScalarValue } from '../interpreter/InterpreterValue' import {SimpleRangeValue} from '../SimpleRangeValue' -import {SearchOptions} from './SearchStrategy' +import {AdvancedFindOptions, SearchOptions} from './SearchStrategy' import {forceNormalizeString} from '../interpreter/ArithmeticHelper' -import {findLastOccurrenceInOrderedRange} from '../interpreter/binarySearch' +import {compare, findLastOccurrenceInOrderedRange} from '../interpreter/binarySearch' + +const NOT_FOUND = -1 export abstract class AdvancedFind { protected constructor( @@ -21,49 +23,81 @@ export abstract class AdvancedFind { ) { } - public advancedFind(keyMatcher: (arg: RawInterpreterValue) => boolean, rangeValue: SimpleRangeValue): number { - let values: InternalScalarValue[] + public advancedFind(keyMatcher: (arg: RawInterpreterValue) => boolean, rangeValue: SimpleRangeValue, { returnOccurrence }: AdvancedFindOptions = { returnOccurrence: 'first' }): number { const range = rangeValue.range - if (range === undefined) { - values = rangeValue.valuesFromTopLeftCorner() - } else { - values = this.dependencyGraph.computeListOfValuesInRange(range) - } - for (let i = 0; i < values.length; i++) { + const values: InternalScalarValue[] = (range === undefined) + ? rangeValue.valuesFromTopLeftCorner() + : this.dependencyGraph.computeListOfValuesInRange(range) + + const initialIterationIndex = returnOccurrence === 'first' ? 0 : values.length-1 + const iterationCondition = returnOccurrence === 'first' ? (i: number) => i < values.length : (i: number) => i >= 0 + const incrementIndex = returnOccurrence === 'first' ? (i: number) => i+1 : (i: number) => i-1 + + for (let i = initialIterationIndex; iterationCondition(i); i = incrementIndex(i)) { if (keyMatcher(getRawValue(values[i]))) { return i } } - return -1 + return NOT_FOUND } - /* - * WARNING: Finding lower/upper bounds in unordered ranges is not supported. When ordering === 'none', assumes matchExactly === true - */ - protected basicFind(searchKey: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, searchCoordinate: 'col' | 'row', { ordering, matchExactly }: SearchOptions): number { + protected basicFind(searchKey: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, searchCoordinate: 'col' | 'row', { ordering, ifNoMatch, returnOccurrence }: SearchOptions): number { const normalizedSearchKey = typeof searchKey === 'string' ? forceNormalizeString(searchKey) : searchKey const range = rangeValue.range if (range === undefined) { - return this.findNormalizedValue(normalizedSearchKey, rangeValue.valuesFromTopLeftCorner()) + return this.findNormalizedValue(normalizedSearchKey, rangeValue.valuesFromTopLeftCorner(), ifNoMatch, returnOccurrence) } if (ordering === 'none') { - return this.findNormalizedValue(normalizedSearchKey, this.dependencyGraph.computeListOfValuesInRange(range)) + return this.findNormalizedValue(normalizedSearchKey, this.dependencyGraph.computeListOfValuesInRange(range), ifNoMatch, returnOccurrence) } return findLastOccurrenceInOrderedRange( normalizedSearchKey, range, - { searchCoordinate, orderingDirection: ordering, matchExactly }, + { searchCoordinate, orderingDirection: ordering, ifNoMatch }, this.dependencyGraph ) } - protected findNormalizedValue(searchKey: RawNoErrorScalarValue, searchArray: InternalScalarValue[]): number { - return searchArray - .map(getRawValue) - .map(val => typeof val === 'string' ? forceNormalizeString(val) : val) - .indexOf(searchKey) + protected findNormalizedValue(searchKey: RawNoErrorScalarValue, searchArray: InternalScalarValue[], ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound' = 'returnNotFound', returnOccurrence: 'first' | 'last' = 'first'): number { + const normalizedArray = searchArray + .map(getRawValue) + .map(val => typeof val === 'string' ? forceNormalizeString(val) : val) + + if (ifNoMatch === 'returnNotFound') { + return returnOccurrence === 'first' ? normalizedArray.indexOf(searchKey) : normalizedArray.lastIndexOf(searchKey) + } + + const compareFn = ifNoMatch === 'returnLowerBound' + ? (left: RawNoErrorScalarValue, right: RawInterpreterValue) => compare(left, right) + : (left: RawNoErrorScalarValue, right: RawInterpreterValue) => -compare(left, right) + + let bestValue: RawNoErrorScalarValue = ifNoMatch === 'returnLowerBound' ? -Infinity : Infinity + let bestIndex = NOT_FOUND + + const initialIterationIndex = returnOccurrence === 'first' ? 0 : normalizedArray.length-1 + const iterationCondition = returnOccurrence === 'first' ? (i: number) => i < normalizedArray.length : (i: number) => i >= 0 + const incrementIndex = returnOccurrence === 'first' ? (i: number) => i+1 : (i: number) => i-1 + + for (let i = initialIterationIndex; iterationCondition(i); i = incrementIndex(i)) { + const value = normalizedArray[i] as RawNoErrorScalarValue + + if (value === searchKey) { + return i + } + + if (compareFn(value, searchKey) > 0) { + continue + } + + if (compareFn(bestValue, value) < 0) { + bestValue = value + bestIndex = i + } + } + + return bestIndex } } diff --git a/src/Lookup/ColumnIndex.ts b/src/Lookup/ColumnIndex.ts index 6082b78cac..8cfa680164 100644 --- a/src/Lookup/ColumnIndex.ts +++ b/src/Lookup/ColumnIndex.ts @@ -23,7 +23,7 @@ import {LazilyTransformingAstService} from '../LazilyTransformingAstService' import {ColumnsSpan, RowsSpan} from '../Span' import {Statistics, StatType} from '../statistics' import {ColumnBinarySearch} from './ColumnBinarySearch' -import {ColumnSearchStrategy, SearchOptions} from './SearchStrategy' +import {AdvancedFindOptions, ColumnSearchStrategy, SearchOptions} from './SearchStrategy' import {Maybe} from '../Maybe' import {AbsoluteCellRange} from '../AbsoluteCellRange' @@ -107,16 +107,16 @@ export class ColumnIndex implements ColumnSearchStrategy { } } - /* - * WARNING: Finding lower/upper bounds in unordered ranges is not supported. When ordering === 'none', assumes matchExactly === true - */ - public find(searchKey: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, { ordering, matchExactly }: SearchOptions): number { - const handlingDuplicates = matchExactly === true ? 'findFirst' : 'findLast' - const resultUsingColumnIndex = this.findUsingColumnIndex(searchKey, rangeValue, handlingDuplicates) - return resultUsingColumnIndex !== undefined ? resultUsingColumnIndex : this.binarySearchStrategy.find(searchKey, rangeValue, { ordering, matchExactly }) + public find(searchKey: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, { ordering, ifNoMatch, returnOccurrence }: SearchOptions): number { + if (returnOccurrence == null) { + returnOccurrence = ordering === 'none' ? 'first' : 'last' + } + + const resultUsingColumnIndex = this.findUsingColumnIndex(searchKey, rangeValue, returnOccurrence) + return resultUsingColumnIndex !== undefined ? resultUsingColumnIndex : this.binarySearchStrategy.find(searchKey, rangeValue, { ordering, ifNoMatch, returnOccurrence }) } - private findUsingColumnIndex(key: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, handlingDuplicates: 'findFirst' | 'findLast'): Maybe { + private findUsingColumnIndex(key: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, returnOccurrence: 'first' | 'last'): Maybe { const range = rangeValue.range if (range === undefined) { return undefined @@ -135,15 +135,15 @@ export class ColumnIndex implements ColumnSearchStrategy { return undefined } - const rowNumber = ColumnIndex.findRowBelongingToRange(valueIndexForTheKey, range, handlingDuplicates) + const rowNumber = ColumnIndex.findRowBelongingToRange(valueIndexForTheKey, range, returnOccurrence) return rowNumber !== undefined ? rowNumber - range.start.row : undefined } - private static findRowBelongingToRange(valueIndex: ValueIndex, range: AbsoluteCellRange, handlingDuplicates: 'findFirst' | 'findLast'): Maybe { + private static findRowBelongingToRange(valueIndex: ValueIndex, range: AbsoluteCellRange, returnOccurrence: 'first' | 'last'): Maybe { const start = range.start.row const end = range.end.row - const positionInIndex = handlingDuplicates === 'findFirst' + const positionInIndex = returnOccurrence === 'first' ? findInOrderedArray(start, valueIndex.index, 'upperBound') : findInOrderedArray(end, valueIndex.index, 'lowerBound') @@ -158,8 +158,8 @@ export class ColumnIndex implements ColumnSearchStrategy { } - public advancedFind(keyMatcher: (arg: RawInterpreterValue) => boolean, range: SimpleRangeValue): number { - return this.binarySearchStrategy.advancedFind(keyMatcher, range) + public advancedFind(keyMatcher: (arg: RawInterpreterValue) => boolean, range: SimpleRangeValue, options: AdvancedFindOptions = { returnOccurrence: 'first' }): number { + return this.binarySearchStrategy.advancedFind(keyMatcher, range, options) } public addColumns(columnsSpan: ColumnsSpan) { diff --git a/src/Lookup/SearchStrategy.ts b/src/Lookup/SearchStrategy.ts index 96202f9487..2184c233a4 100644 --- a/src/Lookup/SearchStrategy.ts +++ b/src/Lookup/SearchStrategy.ts @@ -16,7 +16,12 @@ import {ColumnIndex} from './ColumnIndex' export interface SearchOptions { ordering: 'asc' | 'desc' | 'none', - matchExactly?: boolean, + ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound', + returnOccurrence?: 'first' | 'last', +} + +export interface AdvancedFindOptions { + returnOccurrence?: 'first' | 'last', } export interface SearchStrategy { @@ -25,7 +30,7 @@ export interface SearchStrategy { */ find(searchKey: RawNoErrorScalarValue, range: SimpleRangeValue, options: SearchOptions): number, - advancedFind(keyMatcher: (arg: RawInterpreterValue) => boolean, range: SimpleRangeValue): number, + advancedFind(keyMatcher: (arg: RawInterpreterValue) => boolean, range: SimpleRangeValue, options: AdvancedFindOptions): number, } export interface ColumnSearchStrategy extends SearchStrategy { diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index 1ff9896484..c96292badd 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -232,6 +232,7 @@ const dictionary: RawTranslationPackage = { WEEKNUM: 'WEEKNUM', WORKDAY: 'WORKDAY', 'WORKDAY.INTL': 'WORKDAY.INTL', + XLOOKUP: 'XVYHLEDAT', XNPV: 'XNPV', XOR: 'XOR', YEAR: 'ROK', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 54fd85082f..36406c12d5 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -232,6 +232,7 @@ const dictionary: RawTranslationPackage = { WEEKNUM: 'UGE.NR', WORKDAY: 'ARBEJDSDAG', 'WORKDAY.INTL': 'ARBEJDSDAG.INTL', + XLOOKUP: 'XOPSLAG', XNPV: 'NETTO.NUTIDSVÆRDI', XOR: 'XELLER', YEAR: 'ÅR', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index 354e0fa1fe..1c4937f0d1 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -232,6 +232,7 @@ const dictionary: RawTranslationPackage = { WEEKNUM: 'KALENDERWOCHE', WORKDAY: 'ARBEITSTAG', 'WORKDAY.INTL': 'ARBEITSTAG.INTL', + XLOOKUP: 'XVERWEIS', XNPV: 'XKAPITALWERT', XOR: 'XODER', YEAR: 'JAHR', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index 2f01c82714..1572200d28 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -236,6 +236,7 @@ const dictionary: RawTranslationPackage = { 'WORKDAY.INTL': 'WORKDAY.INTL', XNPV: 'XNPV', XOR: 'XOR', + XLOOKUP: 'XLOOKUP', YEAR: 'YEAR', YEARFRAC: 'YEARFRAC', 'HF.ADD': 'HF.ADD', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index 7b31d38456..e287a751f3 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -232,6 +232,7 @@ export const dictionary: RawTranslationPackage = { WEEKNUM: 'NUM.DE.SEMANA', WORKDAY: 'DIA.LAB', 'WORKDAY.INTL': 'DIA.LAB.INTL', + XLOOKUP: 'BUSCARX', XNPV: 'VNA.NO.PER', XOR: 'XOR', YEAR: 'AÑO', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index b7fdc7735d..b851a3e890 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -232,6 +232,7 @@ const dictionary: RawTranslationPackage = { WEEKNUM: 'VIIKKO.NRO', WORKDAY: 'TYÖPÄIVÄ', 'WORKDAY.INTL': 'TYÖPÄIVÄ.KANSVÄL', + XLOOKUP: 'XHAKU', XNPV: 'NNA.JAKSOTON', XOR: 'EHDOTON.TAI', YEAR: 'VUOSI', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index d3721471db..82a2de12fd 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -232,6 +232,7 @@ const dictionary: RawTranslationPackage = { WEEKNUM: 'NO.SEMAINE', WORKDAY: 'SERIE.JOUR.OUVRE', 'WORKDAY.INTL': 'SERIE.JOUR.OUVRE.INTL', + XLOOKUP: 'RECHERCHEX', XNPV: 'VAN.PAIEMENTS', XOR: 'OUX', YEAR: 'ANNEE', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index 8bc529d76c..538795a7ea 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -232,6 +232,7 @@ const dictionary: RawTranslationPackage = { WEEKNUM: 'HÉT.SZÁMA', WORKDAY: 'KALK.MUNKANAP', 'WORKDAY.INTL': 'KALK.MUNKANAP.INTL', + XLOOKUP: 'XKERES', XNPV: 'XNJÉ', XOR: 'XVAGY', YEAR: 'ÉV', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 43950a0ef5..40972ee0fe 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -232,6 +232,7 @@ const dictionary: RawTranslationPackage = { WEEKNUM: 'NUM.SETTIMANA', WORKDAY: 'GIORNO.LAVORATIVO', 'WORKDAY.INTL': 'GIORNO.LAVORATIVO.INTL', + XLOOKUP: 'CERCA.X', XNPV: 'VAN.X', XOR: 'XOR', YEAR: 'ANNO', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index 2df0ca0b8a..480af8bca1 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -232,6 +232,7 @@ const dictionary: RawTranslationPackage = { WEEKNUM: 'UKENR', WORKDAY: 'ARBEIDSDAG', 'WORKDAY.INTL': 'ARBEIDSDAG.INTL', + XLOOKUP: 'XOPPSLAG', XNPV: 'XNNV', XOR: 'EKSKLUSIVELLER', YEAR: 'ÅR', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index 14800e6c59..d1434094f0 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -232,6 +232,7 @@ const dictionary: RawTranslationPackage = { WEEKNUM: 'WEEKNUMMER', WORKDAY: 'WERKDAG', 'WORKDAY.INTL': 'WERKDAG.INTL', + XLOOKUP: 'X.ZOEKEN', XNPV: 'NHW2', XOR: 'EX.OF', YEAR: 'JAAR', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index 8ce39a6d91..706db6caaa 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -232,6 +232,7 @@ const dictionary: RawTranslationPackage = { WEEKNUM: 'NUM.TYG', WORKDAY: 'DZIEŃ.ROBOCZY', 'WORKDAY.INTL': 'DZIEŃ.ROBOCZY.NIESTAND', + XLOOKUP: 'X.WYSZUKAJ', XNPV: 'XNPV', XOR: 'XOR', YEAR: 'ROK', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index 2c41911a90..73786288bb 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -232,6 +232,7 @@ const dictionary: RawTranslationPackage = { WEEKNUM: 'NÚMSEMANA', WORKDAY: 'DIATRABALHO', 'WORKDAY.INTL': 'DIATRABALHO.INTL', + XLOOKUP: 'PROCX', XNPV: 'XVPL', XOR: 'OUEXCL', YEAR: 'ANO', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index 9f4fbc3cd0..a534784536 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -232,6 +232,7 @@ const dictionary: RawTranslationPackage = { WEEKNUM: 'НОМНЕДЕЛИ', WORKDAY: 'РАБДЕНЬ', 'WORKDAY.INTL': 'РАБДЕНЬ.МЕЖД', + XLOOKUP: 'ПРОСМОТРХ', XNPV: 'ЧИСТНЗ', XOR: 'ИСКЛИЛИ', YEAR: 'ГОД', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index bd49af1397..d6276be2d8 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -232,6 +232,7 @@ const dictionary: RawTranslationPackage = { WEEKNUM: 'VECKONR', WORKDAY: 'ARBETSDAGAR', 'WORKDAY.INTL': 'ARBETSDAGAR.INT', + XLOOKUP: 'XLETAUPP', XNPV: 'XNUVÄRDE', XOR: 'XOR', YEAR: 'ÅR', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index f6f2ca32bb..2c7e8b285b 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -232,6 +232,7 @@ const dictionary: RawTranslationPackage = { WEEKNUM: 'HAFTASAY', WORKDAY: 'İŞGÜNÜ', 'WORKDAY.INTL': 'İŞGÜNÜ.ULUSL', + XLOOKUP: 'ÇAPRAZARA', XNPV: 'ANBD', XOR: 'ÖZELVEYA', YEAR: 'YIL', diff --git a/src/interpreter/binarySearch.ts b/src/interpreter/binarySearch.ts index c5c0dc1058..cef984b642 100644 --- a/src/interpreter/binarySearch.ts +++ b/src/interpreter/binarySearch.ts @@ -16,20 +16,16 @@ const NOT_FOUND = -1 * Options: * - searchCoordinate - must be set to either 'row' or 'col' to indicate the dimension of the search, * - orderingDirection - must be set to either 'asc' or 'desc' to indicate the ordering direction for the search range, - * - matchExactly - when set to false, searches for the lower/upper bound. + * - ifNoMatch - must be set to 'returnLowerBound', 'returnUpperBound' or 'returnNotFound' * - * Semantics: - * - If orderingDirection === 'asc', searches for the lower bound for the searchKey value (unless marchExactly === true). - * - If orderingDirection === 'desc', searches for the upper bound for the searchKey value (unless marchExactly === true). - * - If the search range contains duplicates, returns the last matching value. - * - If no value in the range satisfies the above, returns -1. + * If the search range contains duplicates, returns the last matching value. If no value found in the range satisfies the above, returns -1. * * Note: this function does not normalize input strings. */ export function findLastOccurrenceInOrderedRange( searchKey: RawNoErrorScalarValue, range: AbsoluteCellRange, - { searchCoordinate, orderingDirection, matchExactly }: { searchCoordinate: 'row' | 'col', orderingDirection: 'asc' | 'desc', matchExactly?: boolean }, + { searchCoordinate, orderingDirection, ifNoMatch }: { searchCoordinate: 'row' | 'col', orderingDirection: 'asc' | 'desc', ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound' }, dependencyGraph: DependencyGraph, ): number { const start = range.start[searchCoordinate] @@ -46,15 +42,50 @@ export function findLastOccurrenceInOrderedRange( const foundIndex = findLastMatchingIndex(index => compareFn(searchKey, getValueFromIndexFn(index)) >= 0, start, end) const foundValue = getValueFromIndexFn(foundIndex) - if (foundIndex === NOT_FOUND || typeof foundValue !== typeof searchKey) { - return NOT_FOUND + if (foundValue === searchKey) { + return foundIndex - start } - if (matchExactly && foundValue !== searchKey) { - return NOT_FOUND + if (ifNoMatch === 'returnLowerBound') { + if (foundIndex === NOT_FOUND) { + return orderingDirection === 'asc' ? NOT_FOUND : 0 + } + + if (typeof foundValue !== typeof searchKey) { + return NOT_FOUND + } + + // here: foundValue !== searchKey + if (orderingDirection === 'asc') { + return foundIndex - start + } + + // orderingDirection === 'desc' + const nextIndex = foundIndex+1 + return nextIndex <= end ? nextIndex - start : NOT_FOUND } - return foundIndex - start + if (ifNoMatch === 'returnUpperBound') { + if (foundIndex === NOT_FOUND) { + return orderingDirection === 'asc' ? 0 : NOT_FOUND + } + + if (typeof foundValue !== typeof searchKey) { + return NOT_FOUND + } + + // here: foundValue !== searchKey + if (orderingDirection === 'desc') { + return foundIndex - start + } + + // orderingDirection === 'asc' + const nextIndex = foundIndex+1 + return nextIndex <= end ? nextIndex - start : NOT_FOUND + } + + // ifNoMatch === 'returnNotFound' + return NOT_FOUND } /* diff --git a/src/interpreter/plugin/LookupPlugin.ts b/src/interpreter/plugin/LookupPlugin.ts index 0b33a89cf9..93f064905a 100644 --- a/src/interpreter/plugin/LookupPlugin.ts +++ b/src/interpreter/plugin/LookupPlugin.ts @@ -3,45 +3,64 @@ * Copyright (c) 2024 Handsoncode. All rights reserved. */ -import {AbsoluteCellRange} from '../../AbsoluteCellRange' -import {CellError, ErrorType, simpleCellAddress} from '../../Cell' -import {ErrorMessage} from '../../error-message' -import {RowSearchStrategy} from '../../Lookup/RowSearchStrategy' -import {SearchOptions, SearchStrategy} from '../../Lookup/SearchStrategy' -import {ProcedureAst} from '../../parser' -import {StatType} from '../../statistics' -import {zeroIfEmpty} from '../ArithmeticHelper' -import {InterpreterState} from '../InterpreterState' -import {InternalScalarValue, InterpreterValue, RawNoErrorScalarValue} from '../InterpreterValue' -import {SimpleRangeValue} from '../../SimpleRangeValue' -import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' +import { AbsoluteCellRange } from '../../AbsoluteCellRange' +import { CellError, CellRange, ErrorType, simpleCellAddress } from '../../Cell' +import { ErrorMessage } from '../../error-message' +import { RowSearchStrategy } from '../../Lookup/RowSearchStrategy' +import { SearchOptions, SearchStrategy } from '../../Lookup/SearchStrategy' +import { ProcedureAst } from '../../parser' +import { StatType } from '../../statistics' +import { zeroIfEmpty } from '../ArithmeticHelper' +import { InterpreterState } from '../InterpreterState' +import { InternalScalarValue, InterpreterValue, RawNoErrorScalarValue } from '../InterpreterValue' +import { SimpleRangeValue } from '../../SimpleRangeValue' +import { FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions } from './FunctionPlugin' +import { ArraySize } from '../../ArraySize' export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypecheck { public static implementedFunctions: ImplementedFunctions = { 'VLOOKUP': { method: 'vlookup', parameters: [ - {argumentType: FunctionArgumentType.NOERROR}, - {argumentType: FunctionArgumentType.RANGE}, - {argumentType: FunctionArgumentType.NUMBER}, - {argumentType: FunctionArgumentType.BOOLEAN, defaultValue: true}, - ] + { argumentType: FunctionArgumentType.NOERROR }, + { argumentType: FunctionArgumentType.RANGE }, + { argumentType: FunctionArgumentType.NUMBER }, + { argumentType: FunctionArgumentType.BOOLEAN, defaultValue: true }, + ], }, 'HLOOKUP': { method: 'hlookup', parameters: [ - {argumentType: FunctionArgumentType.NOERROR}, - {argumentType: FunctionArgumentType.RANGE}, - {argumentType: FunctionArgumentType.NUMBER}, - {argumentType: FunctionArgumentType.BOOLEAN, defaultValue: true}, + { argumentType: FunctionArgumentType.NOERROR }, + { argumentType: FunctionArgumentType.RANGE }, + { argumentType: FunctionArgumentType.NUMBER }, + { argumentType: FunctionArgumentType.BOOLEAN, defaultValue: true }, + ] + }, + 'XLOOKUP': { + method: 'xlookup', + arraySizeMethod: 'xlookupArraySize', + parameters: [ + // lookup_value + { argumentType: FunctionArgumentType.NOERROR }, + // lookup_array + { argumentType: FunctionArgumentType.RANGE }, + // return_array + { argumentType: FunctionArgumentType.RANGE }, + // [if_not_found] + { argumentType: FunctionArgumentType.SCALAR, optionalArg: true, defaultValue: ErrorType.NA }, + // [match_mode] + { argumentType: FunctionArgumentType.NUMBER, optionalArg: true, defaultValue: 0 }, + // [search_mode] + { argumentType: FunctionArgumentType.NUMBER, optionalArg: true, defaultValue: 1 }, ] }, 'MATCH': { method: 'match', parameters: [ - {argumentType: FunctionArgumentType.NOERROR}, - {argumentType: FunctionArgumentType.RANGE}, - {argumentType: FunctionArgumentType.NUMBER, defaultValue: 1}, + { argumentType: FunctionArgumentType.NOERROR }, + { argumentType: FunctionArgumentType.RANGE }, + { argumentType: FunctionArgumentType.NUMBER, defaultValue: 1 }, ] }, } @@ -60,14 +79,21 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech if (range === undefined) { return new CellError(ErrorType.VALUE, ErrorMessage.WrongType) } + if (index < 1) { return new CellError(ErrorType.VALUE, ErrorMessage.LessThanOne) } + if (index > range.width()) { return new CellError(ErrorType.REF, ErrorMessage.IndexLarge) } - return this.doVlookup(zeroIfEmpty(key), rangeValue, index - 1, sorted) + const searchOptions: SearchOptions = { + ordering: sorted ? 'asc' : 'none', + ifNoMatch: sorted ? 'returnLowerBound' : 'returnNotFound' + } + + return this.doVlookup(zeroIfEmpty(key), rangeValue, index - 1, searchOptions) }) } @@ -83,36 +109,107 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech if (range === undefined) { return new CellError(ErrorType.VALUE, ErrorMessage.WrongType) } + if (index < 1) { return new CellError(ErrorType.VALUE, ErrorMessage.LessThanOne) } + if (index > range.height()) { return new CellError(ErrorType.REF, ErrorMessage.IndexLarge) } - return this.doHlookup(zeroIfEmpty(key), rangeValue, index - 1, sorted) + const searchOptions: SearchOptions = { + ordering: sorted ? 'asc' : 'none', + ifNoMatch: sorted ? 'returnLowerBound' : 'returnNotFound' + } + + return this.doHlookup(zeroIfEmpty(key), rangeValue, index - 1, searchOptions) + }) + } + + /** + * Corresponds to XLOOKUP(lookup_value, lookup_array, return_array, [if_not_found], [match_mode], [search_mode]) + * + * @param ast + * @param state + */ + public xlookup(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('XLOOKUP'), (key: RawNoErrorScalarValue, lookupRangeValue: SimpleRangeValue, returnRangeValue: SimpleRangeValue, notFoundFlag: any, matchMode: number, searchMode: number) => { + if (![0, -1, 1, 2].includes(matchMode)) { + return new CellError(ErrorType.VALUE, ErrorMessage.BadMode) + } + + if (![1, -1, 2, -2].includes(searchMode)) { + return new CellError(ErrorType.VALUE, ErrorMessage.BadMode) + } + + const lookupRange = lookupRangeValue instanceof SimpleRangeValue ? lookupRangeValue : SimpleRangeValue.fromScalar(lookupRangeValue) + const returnRange = returnRangeValue instanceof SimpleRangeValue ? returnRangeValue : SimpleRangeValue.fromScalar(returnRangeValue) + const isWildcardMatchMode = matchMode === 2 + const searchOptions: SearchOptions = { + ordering: searchMode === 2 ? 'asc' : searchMode === -2 ? 'desc' : 'none', + returnOccurrence: searchMode === -1 ? 'last' : 'first', + ifNoMatch: matchMode === -1 + ? 'returnLowerBound' + : matchMode === 1 + ? 'returnUpperBound' + : 'returnNotFound' + } + + return this.doXlookup(zeroIfEmpty(key), lookupRange, returnRange, notFoundFlag, isWildcardMatchMode, searchOptions) }) } + public xlookupArraySize(ast: ProcedureAst): ArraySize { + const lookupRange = ast?.args?.[1] as CellRange + const returnRange = ast?.args?.[2] as CellRange + + if (lookupRange?.start == null + || lookupRange?.end == null + || returnRange?.start == null + || returnRange?.end == null + ) { + return ArraySize.error() + } + + const lookupRangeHeight = lookupRange.end.row - lookupRange.start.row + 1 + const lookupRangeWidth = lookupRange.end.col - lookupRange.start.col + 1 + const returnRangeHeight = returnRange.end.row - returnRange.start.row + 1 + const returnRangeWidth = returnRange.end.col - returnRange.start.col + 1 + + const isVerticalSearch = lookupRangeWidth === 1 && returnRangeHeight === lookupRangeHeight + const isHorizontalSearch = lookupRangeHeight === 1 && returnRangeWidth === lookupRangeWidth + + if (!isVerticalSearch && !isHorizontalSearch) { + return ArraySize.error() + } + + if (isVerticalSearch) { + return new ArraySize(returnRangeWidth, 1) + } + + return new ArraySize(1, returnRangeHeight) + } + public match(ast: ProcedureAst, state: InterpreterState): InterpreterValue { return this.runFunction(ast.args, state, this.metadata('MATCH'), (key: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, type: number) => { return this.doMatch(zeroIfEmpty(key), rangeValue, type) }) } - protected searchInRange(key: RawNoErrorScalarValue, range: SimpleRangeValue, sorted: boolean, searchStrategy: SearchStrategy): number { - if (!sorted && typeof key === 'string' && this.arithmeticHelper.requiresRegex(key)) { + protected searchInRange(key: RawNoErrorScalarValue, range: SimpleRangeValue, isWildcardMatchMode: boolean, searchOptions: SearchOptions, searchStrategy: SearchStrategy): number { + if (isWildcardMatchMode && typeof key === 'string' && this.arithmeticHelper.requiresRegex(key)) { return searchStrategy.advancedFind( this.arithmeticHelper.eqMatcherFunction(key), - range + range, + { returnOccurrence: searchOptions.returnOccurrence } ) - } else { - const searchOptions: SearchOptions = sorted ? { ordering: 'asc' } : { ordering: 'none', matchExactly: true } - return searchStrategy.find(key, range, searchOptions) } + + return searchStrategy.find(key, range, searchOptions) } - private doVlookup(key: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, index: number, sorted: boolean): InternalScalarValue { + private doVlookup(key: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, index: number, searchOptions: SearchOptions): InternalScalarValue { this.dependencyGraph.stats.start(StatType.VLOOKUP) const range = rangeValue.range let searchedRange @@ -121,7 +218,7 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech } else { searchedRange = SimpleRangeValue.onlyRange(AbsoluteCellRange.spanFrom(range.start, 1, range.height()), this.dependencyGraph) } - const rowIndex = this.searchInRange(key, searchedRange, sorted, this.columnSearch) + const rowIndex = this.searchInRange(key, searchedRange, searchOptions.ordering === 'none', searchOptions, this.columnSearch) this.dependencyGraph.stats.end(StatType.VLOOKUP) @@ -143,7 +240,7 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech return value } - private doHlookup(key: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, index: number, sorted: boolean): InternalScalarValue { + private doHlookup(key: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, index: number, searchOptions: SearchOptions): InternalScalarValue { const range = rangeValue.range let searchedRange if (range === undefined) { @@ -151,7 +248,7 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech } else { searchedRange = SimpleRangeValue.onlyRange(AbsoluteCellRange.spanFrom(range.start, range.width(), 1), this.dependencyGraph) } - const colIndex = this.searchInRange(key, searchedRange, sorted, this.rowSearch) + const colIndex = this.searchInRange(key, searchedRange, searchOptions.ordering === 'none', searchOptions, this.rowSearch) if (colIndex === -1) { return new CellError(ErrorType.NA, ErrorMessage.ValueNotFound) @@ -171,6 +268,25 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech return value } + private doXlookup(key: RawNoErrorScalarValue, lookupRange: SimpleRangeValue, returnRange: SimpleRangeValue, notFoundFlag: any, isWildcardMatchMode: boolean, searchOptions: SearchOptions): InterpreterValue { + const isVerticalSearch = lookupRange.width() === 1 && returnRange.height() === lookupRange.height() + const isHorizontalSearch = lookupRange.height() === 1 && returnRange.width() === lookupRange.width() + + if (!isVerticalSearch && !isHorizontalSearch) { + return new CellError(ErrorType.VALUE, ErrorMessage.WrongDimension) + } + + const searchStrategy = isVerticalSearch ? this.columnSearch : this.rowSearch + const indexFound = this.searchInRange(key, lookupRange, isWildcardMatchMode, searchOptions, searchStrategy) + + if (indexFound === -1) { + return (notFoundFlag == ErrorType.NA) ? new CellError(ErrorType.NA, ErrorMessage.ValueNotFound) : notFoundFlag + } + + const returnValues: InternalScalarValue[][] = isVerticalSearch ? [returnRange.data[indexFound]] : returnRange.data.map((row) => [row[indexFound]]) + return SimpleRangeValue.onlyValues(returnValues) + } + private doMatch(key: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, type: number): InternalScalarValue { if (![-1, 0, 1].includes(type)) { return new CellError(ErrorType.VALUE, ErrorMessage.BadMode) @@ -182,8 +298,8 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech const searchStrategy = rangeValue.width() === 1 ? this.columnSearch : this.rowSearch const searchOptions: SearchOptions = type === 0 - ? { ordering: 'none', matchExactly: true } - : { ordering: type === -1 ? 'desc' : 'asc' } + ? { ordering: 'none', ifNoMatch: 'returnNotFound' } + : { ordering: type === -1 ? 'desc' : 'asc', ifNoMatch: type === -1 ? 'returnUpperBound' : 'returnLowerBound' } const index = searchStrategy.find(key, rangeValue, searchOptions) if (index === -1) { diff --git a/test/column-index.spec.ts b/test/column-index.spec.ts index 92ac6fec47..780dc94b82 100644 --- a/test/column-index.spec.ts +++ b/test/column-index.spec.ts @@ -331,7 +331,7 @@ describe('ColumnIndex#find', () => { const index = buildEmptyIndex(transformService, new Config(), stats) index.add(1, adr('A2')) - const row = index.find(1, SimpleRangeValue.onlyRange(new AbsoluteCellRange(adr('A1'), adr('A3')), undefined!), { ordering: 'asc' }) + const row = index.find(1, SimpleRangeValue.onlyRange(new AbsoluteCellRange(adr('A1'), adr('A3')), undefined!), { ordering: 'asc', ifNoMatch: 'returnNotFound' }) expect(row).toBe(1) }) @@ -341,7 +341,7 @@ describe('ColumnIndex#find', () => { index.add(1, adr('A4')) index.add(1, adr('A10')) - const row = index.find(1, SimpleRangeValue.onlyRange(new AbsoluteCellRange(adr('A1'), adr('A20')), undefined!), { ordering: 'none', matchExactly: true }) + const row = index.find(1, SimpleRangeValue.onlyRange(new AbsoluteCellRange(adr('A1'), adr('A20')), undefined!), { ordering: 'none', ifNoMatch: 'returnNotFound' }) expect(row).toBe(3) }) @@ -351,7 +351,7 @@ describe('ColumnIndex#find', () => { index.add(1, adr('A4')) index.add(1, adr('A10')) - const row = index.find(1, SimpleRangeValue.onlyRange(new AbsoluteCellRange(adr('A1'), adr('A20')), undefined!), { ordering: 'asc' }) + const row = index.find(1, SimpleRangeValue.onlyRange(new AbsoluteCellRange(adr('A1'), adr('A20')), undefined!), { ordering: 'asc', ifNoMatch: 'returnNotFound' }) expect(row).toBe(9) }) @@ -550,12 +550,12 @@ describe('ColumnIndex - lazy crud operations', () => { transformService.addTransformation(new AddRowsTransformer(RowsSpan.fromNumberOfRows(0, 0, 1))) - const rowA = index.find(1, SimpleRangeValue.onlyRange(new AbsoluteCellRange(adr('A1'), adr('A2')), undefined!), { ordering: 'asc' }) + const rowA = index.find(1, SimpleRangeValue.onlyRange(new AbsoluteCellRange(adr('A1'), adr('A2')), undefined!), { ordering: 'asc', ifNoMatch: 'returnNotFound' }) expect(rowA).toEqual(1) expect(index.getValueIndex(0, 0, 1).index).toEqual([1]) expect(index.getValueIndex(0, 1, 1).index).toEqual([0]) - const rowB = index.find(1, SimpleRangeValue.onlyRange(new AbsoluteCellRange(adr('B1'), adr('B2')), undefined!), { ordering: 'asc' }) + const rowB = index.find(1, SimpleRangeValue.onlyRange(new AbsoluteCellRange(adr('B1'), adr('B2')), undefined!), { ordering: 'asc', ifNoMatch: 'returnNotFound' }) expect(rowB).toEqual(1) expect(index.getValueIndex(0, 0, 1).index).toEqual([1]) expect(index.getValueIndex(0, 1, 1).index).toEqual([1]) @@ -570,12 +570,12 @@ describe('ColumnIndex - lazy crud operations', () => { transformService.addTransformation(new AddRowsTransformer(RowsSpan.fromNumberOfRows(0, 0, 1))) - const row1 = index.find(1, SimpleRangeValue.onlyRange(new AbsoluteCellRange(adr('A1'), adr('A3')), undefined!), { ordering: 'asc' }) + const row1 = index.find(1, SimpleRangeValue.onlyRange(new AbsoluteCellRange(adr('A1'), adr('A3')), undefined!), { ordering: 'asc', ifNoMatch: 'returnNotFound' }) expect(row1).toEqual(1) expect(index.getValueIndex(0, 0, 1).index).toEqual([1]) expect(index.getValueIndex(0, 0, 2).index).toEqual([1]) - const row2 = index.find(2, SimpleRangeValue.onlyRange(new AbsoluteCellRange(adr('A1'), adr('A3')), undefined!), { ordering: 'asc' }) + const row2 = index.find(2, SimpleRangeValue.onlyRange(new AbsoluteCellRange(adr('A1'), adr('A3')), undefined!), { ordering: 'asc', ifNoMatch: 'returnNotFound' }) expect(row2).toEqual(2) expect(index.getValueIndex(0, 0, 1).index).toEqual([1]) expect(index.getValueIndex(0, 0, 2).index).toEqual([2]) diff --git a/test/interpreter/binary-search.spec.ts b/test/interpreter/binary-search.spec.ts index 989f2f585d..5a7a757ddc 100644 --- a/test/interpreter/binary-search.spec.ts +++ b/test/interpreter/binary-search.spec.ts @@ -3,29 +3,29 @@ import {EmptyValue} from '../../src/interpreter/InterpreterValue' import {CellError, ErrorType} from '../../src' describe('findLastOccurrenceInOrderedArray', () => { - it('should return -1 when empty array', () => { + it('returns -1 when empty array', () => { const values: number[] = [] expect(findLastOccurrenceInOrderedArray(1, values)).toBe(-1) }) - it('should work for one element', () => { + it('works for one element', () => { const values: number[] = [1] expect(findLastOccurrenceInOrderedArray(1, values)).toBe(0) }) - it('should return -1 when all elements are greater', () => { + it('returns -1 when all elements are greater', () => { const values: number[] = [3, 5, 10] expect(findLastOccurrenceInOrderedArray(1, values)).toBe(-1) }) - it('should find index of element in values of odd length', () => { + it('finds index of element in values of odd length', () => { const values: number[] = [3, 5, 10] expect(findLastOccurrenceInOrderedArray(3, values)).toBe(0) expect(findLastOccurrenceInOrderedArray(5, values)).toBe(1) expect(findLastOccurrenceInOrderedArray(10, values)).toBe(2) }) - it('should find index of element in values of even length', () => { + it('finds index of element in values of even length', () => { const values: number[] = [3, 5, 10, 11] expect(findLastOccurrenceInOrderedArray(3, values)).toBe(0) expect(findLastOccurrenceInOrderedArray(5, values)).toBe(1) @@ -33,23 +33,23 @@ describe('findLastOccurrenceInOrderedArray', () => { expect(findLastOccurrenceInOrderedArray(11, values)).toBe(3) }) - it('should find index of lower bound', () => { + it('finds index of lower bound', () => { const values: number[] = [1, 2, 3, 7] expect(findLastOccurrenceInOrderedArray(5, values)).toBe(2) expect(findLastOccurrenceInOrderedArray(10, values)).toBe(3) }) - it('should work for strings', () => { + it('works for strings', () => { const values: string[] = ['aaaa', 'bar', 'foo', 'xyz'] expect(findLastOccurrenceInOrderedArray('foo', values)).toBe(2) }) - it('should work for bools', () => { + it('works for bools', () => { const values: boolean[] = [false, false, false, true, true] expect(findLastOccurrenceInOrderedArray(true, values)).toBe(4) }) - it('should work for different types in array', () => { + it('works for different types in array', () => { const values = [3, 5, 7, 'aaaa', 'bar', 'foo', false, false, true] expect(findLastOccurrenceInOrderedArray(5, values)).toBe(1) expect(findLastOccurrenceInOrderedArray('foo', values)).toBe(5) @@ -58,12 +58,12 @@ describe('findLastOccurrenceInOrderedArray', () => { expect(findLastOccurrenceInOrderedArray('xyz', values)).toBe(5) }) - it('should return the last occurence', () => { + it('returns the last occurrence', () => { const values = [1, 2, 2, 2, 2, 2, 3, 3, 3] expect(findLastOccurrenceInOrderedArray(2, values)).toBe(5) }) - it('should work for arrays ordered descending', () => { + it('works for arrays ordered descending', () => { const values: number[] = [11, 10, 5, 3] expect(findLastOccurrenceInOrderedArray(3, values, 'desc')).toBe(3) expect(findLastOccurrenceInOrderedArray(5, values, 'desc')).toBe(2) diff --git a/test/interpreter/function-substitute.spec.ts b/test/interpreter/function-substitute.spec.ts index 7890bd4535..b72c0f1f60 100644 --- a/test/interpreter/function-substitute.spec.ts +++ b/test/interpreter/function-substitute.spec.ts @@ -39,7 +39,7 @@ describe('Function SUBSTITUTE', () => { expect(engine.getCellValue(adr('A4'))).toEqual('fofofofufo') }) - it('should return the original text if there are not enough occurences of the search string', () => { + it('should return the original text if there are not enough occurrences of the search string', () => { const engine = HyperFormula.buildFromArray([ ['=SUBSTITUTE("foobar", "o", "BAZ", 3)'], ]) diff --git a/test/interpreter/function-xlookup.spec.ts b/test/interpreter/function-xlookup.spec.ts new file mode 100644 index 0000000000..f1e0f30767 --- /dev/null +++ b/test/interpreter/function-xlookup.spec.ts @@ -0,0 +1,1276 @@ +import { HyperFormula, ErrorType } from '../../src' +import { ErrorMessage } from '../../src/error-message' +import { adr, detailedError } from '../testUtils' +import { AbsoluteCellRange } from '../../src/AbsoluteCellRange' + +describe('Function XLOOKUP', () => { + describe('validates arguments', () => { + it('returns error when less than 3 arguments', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(1, A2:B3)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.NA, ErrorMessage.WrongArgNumber)) + }) + + it('returns error when more than 5 arguments', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(1, A2:A3, B2:B3, "foo", 0, 1, 42)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.NA, ErrorMessage.WrongArgNumber)) + }) + + it('returns error when shapes of lookupArray and returnArray are incompatible', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(1, B1:B10, C1:C9)'], // returnArray too short + ['=XLOOKUP(1, B1:B10, C1:C11)'], // returnArray too long + ['=XLOOKUP(1, B1:B10, C1:D5)'], // returnArray too short + ['=XLOOKUP(1, B1:E1, B2:D2)'], // returnArray too short + ['=XLOOKUP(1, B1:E1, B2:F2)'], // returnArray too long + ['=XLOOKUP(1, B1:E1, B2:C3)'], // returnArray too short + ['=XLOOKUP(1, B1:B3, C1:E1)'], // transposed + ['=XLOOKUP(1, C1:E1, B1:B3)'], // transposed + ['=XLOOKUP(1, B1:C2, D3:E4)'], // lookupArray: 2d range + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.WrongDimension)) + expect(engine.getCellValue(adr('A2'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.WrongDimension)) + expect(engine.getCellValue(adr('A3'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.WrongDimension)) + expect(engine.getCellValue(adr('A4'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.WrongDimension)) + expect(engine.getCellValue(adr('A5'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.WrongDimension)) + expect(engine.getCellValue(adr('A6'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.WrongDimension)) + expect(engine.getCellValue(adr('A7'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.WrongDimension)) + expect(engine.getCellValue(adr('A8'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.WrongDimension)) + expect(engine.getCellValue(adr('A9'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.WrongDimension)) + }) + + it('returns error when matchMode is of wrong type', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(1, B1:B2, C1:C2, 0, -2)'], + ['=XLOOKUP(1, B1:B2, C1:C2, 0, 3)'], + ['=XLOOKUP(1, B1:B2, C1:C2, 0, 0.5)'], + ['=XLOOKUP(1, B1:B2, C1:C2, 0, "string")'], + ['=XLOOKUP(1, B1:B2, C1:C2, 0, B1:B2)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.BadMode)) + expect(engine.getCellValue(adr('A2'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.BadMode)) + expect(engine.getCellValue(adr('A3'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.BadMode)) + expect(engine.getCellValue(adr('A4'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.NumberCoercion)) + expect(engine.getCellValue(adr('A5'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.WrongType)) + }) + + it('returns error when searchMode is of wrong type', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(1, B1:B2, C1:C2, 0, 0, -3)'], + ['=XLOOKUP(1, B1:B2, C1:C2, 0, 0, 3)'], + ['=XLOOKUP(1, B1:B2, C1:C2, 0, 0, 0)'], + ['=XLOOKUP(1, B1:B2, C1:C2, 0, 0, 0.5)'], + ['=XLOOKUP(1, B1:B2, C1:C2, 0, 0, "string")'], + ['=XLOOKUP(1, B1:B2, C1:C2, 0, 0, D1:D2)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.BadMode)) + expect(engine.getCellValue(adr('A2'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.BadMode)) + expect(engine.getCellValue(adr('A3'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.BadMode)) + expect(engine.getCellValue(adr('A4'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.BadMode)) + expect(engine.getCellValue(adr('A5'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.NumberCoercion)) + expect(engine.getCellValue(adr('A6'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.WrongType)) + }) + + it('propagates errors properly', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(1/0, B1:B1, 1)'], + ['=XLOOKUP(1, B1:B1, 1/0)'], + ['=XLOOKUP(1, A10:A11, NA())'] + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.DIV_BY_ZERO)) + expect(engine.getCellValue(adr('A2'))).toEqualError(detailedError(ErrorType.DIV_BY_ZERO)) + expect(engine.getCellValue(adr('A3'))).toEqualError(detailedError(ErrorType.NA)) + }) + }) + + describe('with default matchMode and searchMode', () => { + it('finds value in a sorted row', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(2, B1:D1, B1:D1)', 1, 2, 3], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqual(2) + }) + + it('finds value in an unsorted row', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(2, B1:D1, B1:D1)', 4, 2, 3], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqual(2) + }) + + it('finds value in a sorted column', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(2, B1:B3, B1:B3)', 1], + ['', 2], + ['', 3], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqual(2) + }) + + it('finds value in an unsorted column', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(2, B1:B3, B1:B3)', 4], + ['', 2], + ['', 3], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqual(2) + }) + + it('when key is not found, returns ifNotFound value or NA error', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(2, B1:B3, B1:B3)'], + ['=XLOOKUP(2, B1:D1, B1:D1)'], + ['=XLOOKUP(2, B1:B3, B1:B3, "not found")'], + ['=XLOOKUP(2, B1:D1, B1:D1, "not found")'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.NA, ErrorMessage.ValueNotFound)) + expect(engine.getCellValue(adr('A2'))).toEqualError(detailedError(ErrorType.NA, ErrorMessage.ValueNotFound)) + expect(engine.getCellValue(adr('A3'))).toEqual('not found') + expect(engine.getCellValue(adr('A4'))).toEqual('not found') + }) + + it('works when returnArray is shifted (vertical search)', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(2, B1:B3, C11:C13)', 1], + ['', 2], + ['', 3], + [], + [], + [], + [], + [], + [], + [], + ['', '', 'a'], + ['', '', 'b'], + ['', '', 'c'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqual('b') + }) + + it('works when returnArray is shifted (horizontal search)', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(2, B1:D1, C2:E2)', '1', '2', '3'], + ['', '', 'a', 'b', 'c'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqual('b') + }) + + it('should not perform the wildcard match unless matchMode=2', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP("a?b*", A2:E2, A2:E2)'], + ['a', 'axxb', 'a1b111', 'a2b222', 'x'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.NA, ErrorMessage.ValueNotFound)) + }) + + it('should not perform the wildcard match unless matchMode=2 (ColumnIndex)', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP("a?b*", A2:E2, A2:E2)'], + ['a', 'axxb', 'a1b111', 'a2b222', 'x'], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.NA, ErrorMessage.ValueNotFound)) + }) + + describe('when lookupArray is a single-cell range', () => { + it('returns single cell, when returnArray is also a single-cell range', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(1, B1:B1, C1:C1)', 1, 'a'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqual('a') + }) + + it('returns a vertical range, when returnArray is a vertical range', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(1, B1:B1, A3:A4)', 1], + [], + ['b'], + ['c'] + ]) + + expect(engine.getCellValue(adr('A1'))).toEqual('b') + expect(engine.getCellValue(adr('A2'))).toEqual('c') + }) + + it('returns a horizontal range, when returnArray is a horizontal range', () => { + const engine = HyperFormula.buildFromArray([ + [1, 'b', 'c'], + ['=XLOOKUP(1, A1:A1, B1:C1)'], + ]) + + expect(engine.getCellValue(adr('A2'))).toEqual('b') + expect(engine.getCellValue(adr('B2'))).toEqual('c') + }) + }) + + it('finds an empty cell', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP("", B1:D1, B2:D2)', 1, 2, ''], + ['', 'a', 'b', 'c'] + ]) + + expect(engine.getCellValue(adr('A1'))).toEqual('c') + }) + }) + + describe('with BinarySearch column search strategy, when provided with searchMode = ', () => { + it('1, finds the first match in unsorted horizontal range', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(1, A2:E2, A3:E3, "NotFound", 0, 1)'], + [2, 1, 3, 1, 4], + [1, 2, 3, 4, 5], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual(2) + }) + + it('1, finds the first match in unsorted vertical range', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(1, A2:A6, B2:B6, "NotFound", 0, 1)'], + [2, 1], + [1, 2], + [3, 3], + [1, 4], + [4, 5] + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual(2) + }) + + it('1, returns "NotFound" if there is no match', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(5, A2:A6, B2:B6, "NotFound", 0, 1)'], + [2, 1], + [1, 2], + [3, 3], + [1, 4], + [4, 5] + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual('NotFound') + }) + + it('-1, finds the last match in unsorted horizontal range', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(1, A2:E2, A3:E3, "NotFound", 0, -1)'], + [2, 1, 3, 1, 4], + [1, 2, 3, 4, 5], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual(4) + }) + + it('-1, finds the last match in unsorted vertical range', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(1, A2:A6, B2:B6, "NotFound", 0, -1)'], + [2, 1], + [1, 2], + [3, 3], + [1, 4], + [4, 5] + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual(4) + }) + + it('-1, returns "NotFound" if there is no match', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(5, A2:A6, B2:B6, "NotFound", 0, -1)'], + [2, 1], + [1, 2], + [3, 3], + [1, 4], + [4, 5] + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual('NotFound') + }) + + it('2, finds the value in horizontal range sorted ascending', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(2, A2:E2, A2:E2, "NotFound", 0, 2)'], + [1, 2, 2, 5, 5], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual(2) + }) + + it('2, finds the value in vertical range sorted ascending', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(2, A2:A6, A2:A6, "NotFound", 0, 2)'], + [1], + [2], + [2], + [5], + [5], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual(2) + }) + + it('2, returns "NotFound" if there is no match in a range sorted ascending', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(3, A2:A6, A2:A6, "NotFound", 0, 2)'], + [1], + [2], + [2], + [5], + [5], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual('NotFound') + }) + + it('-2, finds the value in horizontal range sorted descending', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(2, A2:E2, A2:E2, "NotFound", 0, -2)'], + [5, 2, 2, 1, 1], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual(2) + }) + + it('-2, finds the value in vertical range sorted descending', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(2, A2:A6, A2:A6, "NotFound", 0, -2)'], + [5], + [2], + [2], + [1], + [1], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual(2) + }) + + it('-2, returns "NotFound" if there is no match in a range sorted descending', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(3, A2:A6, A2:A6, "NotFound", 0, -2)'], + [5], + [2], + [2], + [1], + [1], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual('NotFound') + }) + }) + + describe('with ColumnIndex column search strategy, when provided with searchMode = ', () => { + it('1, finds the first match in unsorted horizontal range', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(1, A2:E2, A3:E3, "NotFound", 0, 1)'], + [2, 1, 3, 1, 4], + [1, 2, 3, 4, 5], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual(2) + }) + + it('1, finds the first match in unsorted vertical range', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(1, A2:A6, B2:B6, "NotFound", 0, 1)'], + [2, 1], + [1, 2], + [3, 3], + [1, 4], + [4, 5] + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual(2) + }) + + it('1, returns "NotFound" if there is no match', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(5, A2:A6, B2:B6, "NotFound", 0, 1)'], + [2, 1], + [1, 2], + [3, 3], + [1, 4], + [4, 5] + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual('NotFound') + }) + + it('-1, finds the last match in unsorted horizontal range', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(1, A2:E2, A3:E3, "NotFound", 0, -1)'], + [2, 1, 3, 1, 4], + [1, 2, 3, 4, 5], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual(4) + }) + + it('-1, finds the last match in unsorted vertical range', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(1, A2:A6, B2:B6, "NotFound", 0, -1)'], + [2, 1], + [1, 2], + [3, 3], + [1, 4], + [4, 5] + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual(4) + }) + + it('-1, returns "NotFound" if there is no match', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(5, A2:A6, B2:B6, "NotFound", 0, -1)'], + [2, 1], + [1, 2], + [3, 3], + [1, 4], + [4, 5] + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual('NotFound') + }) + + it('2, finds the value in horizontal range sorted ascending', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(2, A2:E2, A2:E2, "NotFound", 0, 2)'], + [1, 2, 2, 5, 5], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual(2) + }) + + it('2, finds the value in vertical range sorted ascending', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(2, A2:A6, A2:A6, "NotFound", 0, 2)'], + [1], + [2], + [2], + [5], + [5], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual(2) + }) + + it('2, returns "NotFound" if there is no match in a range sorted ascending', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(3, A2:A6, A2:A6, "NotFound", 0, 2)'], + [1], + [2], + [2], + [5], + [5], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual('NotFound') + }) + + it('-2, finds the value in horizontal range sorted descending', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(2, A2:E2, A2:E2, "NotFound", 0, -2)'], + [5, 2, 2, 1, 1], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual(2) + }) + + it('-2, finds the value in vertical range sorted descending', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(2, A2:A6, A2:A6, "NotFound", 0, -2)'], + [5], + [2], + [2], + [1], + [1], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual(2) + }) + + it('-2, returns "NotFound" if there is no match in a range sorted descending', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(3, A2:A6, A2:A6, "NotFound", 0, -2)'], + [5], + [2], + [2], + [1], + [1], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual('NotFound') + }) + }) + + describe('with BinarySearch column search strategy, when provided with matchMode = ', () => { + describe('-1 (looking for a lower bound)', () => { + describe('in array ordered ascending', () => { + it('returns exact match if exists', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", -1, 2)'], + [1, 2, 42, 50, 51], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual(42) + }) + + it('returns a lower bound when there is no exact match', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", -1, 2)'], + [1, 2, 40, 50, 51], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual(40) + }) + + it('returns a lower bound when all elements are smaller than the key', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", -1, 2)'], + [1, 2, 3, 4, 5], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual(5) + }) + + it('returns NotFound when all elements are greater than the key', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", -1, 2)'], + [43, 44, 45, 46, 47], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual('NotFound') + }) + }) + + describe('in array ordered descending', () => { + it('returns exact match if exists', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", -1, -2)'], + [55, 54, 42, 2, 1], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual(42) + }) + + it('returns a lower bound when there is no exact match', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", -1, -2)'], + [55, 54, 40, 2, 1], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual(40) + }) + + it('returns a lower bound when all elements are smaller than the key', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", -1, -2)'], + [5, 4, 3, 2, 1], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual(5) + }) + + it('returns NotFound when all elements are greater than the key', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", -1, -2)'], + [100, 90, 80, 70, 60], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual('NotFound') + }) + }) + + it('returns a lower bound if there is no match in unsorted horizontal range', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(3, A2:E2, A3:E3, "NotFound", -1, 1)'], + [2, 1, 4, 2, 5], + [1, 2, 3, 4, 5], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual(1) + }) + + it('returns a lower bound if there is no match in unsorted vertical range', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(3, A2:A6, B2:B6, "NotFound", -1, 1)'], + [2, 1], + [1, 2], + [4, 3], + [2, 4], + [5, 5] + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual(1) + }) + }) + + describe('1 (looking for a upper bound', () => { + describe('in array ordered ascending', () => { + it('returns exact match if exists', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", 1, 2)'], + [1, 2, 42, 50, 51], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual(42) + }) + + it('returns an upper bound when there is no exact match', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", 1, 2)'], + [1, 2, 44, 50, 51], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual(44) + }) + + it('returns NotFound when all elements are smaller than the key', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", 1, 2)'], + [1, 2, 3, 4, 5], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual('NotFound') + }) + + it('returns an upper bound when all elements are greater than the key', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", 1, 2)'], + [43, 44, 45, 46, 47], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual(43) + }) + }) + + describe('in array ordered descending', () => { + it('returns exact match if exists', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", 1, -2)'], + [55, 54, 42, 2, 1], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual(42) + }) + + it('returns an upper bound when there is no exact match', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", 1, -2)'], + [55, 54, 44, 2, 1], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual(44) + }) + + it('returns NotFound when all elements are smaller than the key', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", 1, -2)'], + [5, 4, 3, 2, 1], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual('NotFound') + }) + + it('returns an upper bound when all elements are greater than the key', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", 1, -2)'], + [100, 90, 80, 70, 60], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual(60) + }) + }) + + it('returns an upper bound if there is no match in unsorted horizontal range', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(3, A2:E2, A3:E3, "NotFound", 1, 1)'], + [2, 1, 4, 2, 5], + [1, 2, 3, 4, 5], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual(3) + }) + + it('returns an upper bound if there is no match in unsorted vertical range', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(3, A2:A6, B2:B6, "NotFound", 1, 1)'], + [2, 1], + [1, 2], + [4, 3], + [2, 4], + [5, 5] + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual(3) + }) + }) + + describe('2 (wildcard match)', () => { + describe('for a horizontal range', () => { + it('when searchMode = 1, returns the first matching item', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP("a?b*", A2:E2, A2:E2, "NotFound", 2, 1)'], + ['a', 'axxb', 'a1b111', 'a2b222', 'x'], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual('a1b111') + }) + + it('when searchMode = 2, returns the first matching item', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP("a?b*", A2:E2, A2:E2, "NotFound", 2, 2)'], + ['a', 'axxb', 'a1b111', 'a2b222', 'x'], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual('a1b111') + }) + + it('when searchMode = -2, returns the first matching item', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP("a?b*", A2:E2, A2:E2, "NotFound", 2, -2)'], + ['a', 'axxb', 'a1b111', 'a2b222', 'x'], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual('a1b111') + }) + + it('when searchMode = -1, returns the last matching item', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP("a?b*", A2:E2, A2:E2, "NotFound", 2, -1)'], + ['a', 'axxb', 'a1b111', 'a2b222', 'x'], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual('a2b222') + }) + + it('when there are no matching items, returns NotFound (all searchModes)', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP("t?b*", A5:E5, A5:E5, "NotFound", 2, 1)'], + ['=XLOOKUP("t?b*", A5:E5, A5:E5, "NotFound", 2, -1)'], + ['=XLOOKUP("t?b*", A5:E5, A5:E5, "NotFound", 2, 2)'], + ['=XLOOKUP("t?b*", A5:E5, A5:E5, "NotFound", 2, -2)'], + ['a', 'axxb', 'a1b111', 'a2b222', 'x'], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual('NotFound') + expect(engine.getCellValue(adr('A2'))).toEqual('NotFound') + expect(engine.getCellValue(adr('A3'))).toEqual('NotFound') + expect(engine.getCellValue(adr('A4'))).toEqual('NotFound') + }) + }) + + describe('for a vertical range', () => { + it('when searchMode = 1, returns the first matching item', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP("a?b*", A2:A6, A2:A6, "NotFound", 2, 1)'], + ['a'], + ['axxb'], + ['a1b111'], + ['a2b222'], + ['x'], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual('a1b111') + }) + + it('when searchMode = 2, returns the first matching item', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP("a?b*", A2:A6, A2:A6, "NotFound", 2, 2)'], + ['a'], + ['axxb'], + ['a1b111'], + ['a2b222'], + ['x'], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual('a1b111') + }) + + it('when searchMode = -2, returns the first matching item', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP("a?b*", A2:A6, A2:A6, "NotFound", 2, -2)'], + ['a'], + ['axxb'], + ['a1b111'], + ['a2b222'], + ['x'], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual('a1b111') + }) + + it('when searchMode = -1, returns the last matching item', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP("a?b*", A2:A6, A2:A6, "NotFound", 2, -1)'], + ['a'], + ['axxb'], + ['a1b111'], + ['a2b222'], + ['x'], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual('a2b222') + }) + + it('when there are no matching items, returns NotFound (all searchModes)', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP("t?b*", A5:A9, A5:A9, "NotFound", 2, 1)'], + ['=XLOOKUP("t?b*", A5:A9, A5:A9, "NotFound", 2, -1)'], + ['=XLOOKUP("t?b*", A5:A9, A5:A9, "NotFound", 2, 2)'], + ['=XLOOKUP("t?b*", A5:A9, A5:A9, "NotFound", 2, -2)'], + ['a'], + ['axxb'], + ['a1b111'], + ['a2b222'], + ['x'], + ], { useColumnIndex: false }) + + expect(engine.getCellValue(adr('A1'))).toEqual('NotFound') + expect(engine.getCellValue(adr('A2'))).toEqual('NotFound') + expect(engine.getCellValue(adr('A3'))).toEqual('NotFound') + expect(engine.getCellValue(adr('A4'))).toEqual('NotFound') + }) + }) + }) + }) + + describe('with ColumnIndex column search strategy, when provided with matchMode = ', () => { + describe('-1 (looking for a lower bound', () => { + describe('in array ordered ascending', () => { + it('returns exact match if exists', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", -1, 2)'], + [1, 2, 42, 50, 51], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual(42) + }) + + it('returns a lower bound when there is no exact match', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", -1, 2)'], + [1, 2, 40, 50, 51], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual(40) + }) + + it('returns a lower bound when all elements are smaller than the key', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", -1, 2)'], + [1, 2, 3, 4, 5], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual(5) + }) + + it('returns NotFound when all elements are greater than the key', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", -1, 2)'], + [43, 44, 45, 46, 47], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual('NotFound') + }) + }) + + describe('in array ordered descending', () => { + it('returns exact match if exists', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", -1, -2)'], + [55, 54, 42, 2, 1], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual(42) + }) + + it('returns a lower bound when there is no exact match', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", -1, -2)'], + [55, 54, 40, 2, 1], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual(40) + }) + + it('returns a lower bound when all elements are smaller than the key', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", -1, -2)'], + [5, 4, 3, 2, 1], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual(5) + }) + + it('returns NotFound when all elements are greater than the key', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", -1, -2)'], + [100, 90, 80, 70, 60], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual('NotFound') + }) + }) + + it('returns a lower bound if there is no match in unsorted horizontal range', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(3, A2:E2, A3:E3, "NotFound", -1, 1)'], + [2, 1, 4, 2, 5], + [1, 2, 3, 4, 5], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual(1) + }) + + it('returns a lower bound if there is no match in unsorted vertical range', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(3, A2:A6, B2:B6, "NotFound", -1, 1)'], + [2, 1], + [1, 2], + [4, 3], + [2, 4], + [5, 5] + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual(1) + }) + }) + + describe('1 (looking for a upper bound', () => { + describe('in array ordered ascending', () => { + it('returns exact match if exists', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", 1, 2)'], + [1, 2, 42, 50, 51], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual(42) + }) + + it('returns an upper bound when there is no exact match', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", 1, 2)'], + [1, 2, 44, 50, 51], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual(44) + }) + + it('returns NotFound when all elements are smaller than the key', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", 1, 2)'], + [1, 2, 3, 4, 5], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual('NotFound') + }) + + it('returns an upper bound when all elements are greater than the key', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", 1, 2)'], + [43, 44, 45, 46, 47], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual(43) + }) + }) + + describe('in array ordered descending', () => { + it('returns exact match if exists', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", 1, -2)'], + [55, 54, 42, 2, 1], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual(42) + }) + + it('returns an upper bound when there is no exact match', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", 1, -2)'], + [55, 54, 44, 2, 1], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual(44) + }) + + it('returns NotFound when all elements are smaller than the key', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", 1, -2)'], + [5, 4, 3, 2, 1], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual('NotFound') + }) + + it('returns an upper bound when all elements are greater than the key', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(42, A2:E2, A2:E2, "NotFound", 1, -2)'], + [100, 90, 80, 70, 60], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual(60) + }) + }) + + it('returns an upper bound if there is no match in unsorted horizontal range', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(3, A2:E2, A3:E3, "NotFound", 1, 1)'], + [2, 1, 4, 2, 5], + [1, 2, 3, 4, 5], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual(3) + }) + + it('returns an upper bound if there is no match in unsorted vertical range', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP(3, A2:A6, B2:B6, "NotFound", 1, 1)'], + [2, 1], + [1, 2], + [4, 3], + [2, 4], + [5, 5] + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual(3) + }) + }) + + describe('2 (wildcard match)', () => { + describe('for a horizontal range', () => { + it('when searchMode = 1, returns the first matching item', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP("a?b*", A2:E2, A2:E2, "NotFound", 2, 1)'], + ['a', 'axxb', 'a1b111', 'a2b222', 'x'], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual('a1b111') + }) + + it('when searchMode = 2, returns the first matching item', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP("a?b*", A2:E2, A2:E2, "NotFound", 2, 2)'], + ['a', 'axxb', 'a1b111', 'a2b222', 'x'], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual('a1b111') + }) + + it('when searchMode = -2, returns the first matching item', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP("a?b*", A2:E2, A2:E2, "NotFound", 2, -2)'], + ['a', 'axxb', 'a1b111', 'a2b222', 'x'], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual('a1b111') + }) + + it('when searchMode = -1, returns the last matching item', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP("a?b*", A2:E2, A2:E2, "NotFound", 2, -1)'], + ['a', 'axxb', 'a1b111', 'a2b222', 'x'], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual('a2b222') + }) + + it('when there are no matching items, returns NotFound (all searchModes)', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP("t?b*", A5:E5, A5:E5, "NotFound", 2, 1)'], + ['=XLOOKUP("t?b*", A5:E5, A5:E5, "NotFound", 2, -1)'], + ['=XLOOKUP("t?b*", A5:E5, A5:E5, "NotFound", 2, 2)'], + ['=XLOOKUP("t?b*", A5:E5, A5:E5, "NotFound", 2, -2)'], + ['a', 'axxb', 'a1b111', 'a2b222', 'x'], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual('NotFound') + expect(engine.getCellValue(adr('A2'))).toEqual('NotFound') + expect(engine.getCellValue(adr('A3'))).toEqual('NotFound') + expect(engine.getCellValue(adr('A4'))).toEqual('NotFound') + }) + }) + + describe('for a vertical range', () => { + it('when searchMode = 1, returns the first matching item', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP("a?b*", A2:A6, A2:A6, "NotFound", 2, 1)'], + ['a'], + ['axxb'], + ['a1b111'], + ['a2b222'], + ['x'], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual('a1b111') + }) + + it('when searchMode = 2, returns the first matching item', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP("a?b*", A2:A6, A2:A6, "NotFound", 2, 2)'], + ['a'], + ['axxb'], + ['a1b111'], + ['a2b222'], + ['x'], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual('a1b111') + }) + + it('when searchMode = -2, returns the first matching item', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP("a?b*", A2:A6, A2:A6, "NotFound", 2, -2)'], + ['a'], + ['axxb'], + ['a1b111'], + ['a2b222'], + ['x'], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual('a1b111') + }) + + it('when searchMode = -1, returns the last matching item', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP("a?b*", A2:A6, A2:A6, "NotFound", 2, -1)'], + ['a'], + ['axxb'], + ['a1b111'], + ['a2b222'], + ['x'], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual('a2b222') + }) + + it('when there are no matching items, returns NotFound (all searchModes)', () => { + const engine = HyperFormula.buildFromArray([ + ['=XLOOKUP("t?b*", A5:A9, A5:A9, "NotFound", 2, 1)'], + ['=XLOOKUP("t?b*", A5:A9, A5:A9, "NotFound", 2, -1)'], + ['=XLOOKUP("t?b*", A5:A9, A5:A9, "NotFound", 2, 2)'], + ['=XLOOKUP("t?b*", A5:A9, A5:A9, "NotFound", 2, -2)'], + ['a'], + ['axxb'], + ['a1b111'], + ['a2b222'], + ['x'], + ], { useColumnIndex: true }) + + expect(engine.getCellValue(adr('A1'))).toEqual('NotFound') + expect(engine.getCellValue(adr('A2'))).toEqual('NotFound') + expect(engine.getCellValue(adr('A3'))).toEqual('NotFound') + expect(engine.getCellValue(adr('A4'))).toEqual('NotFound') + }) + }) + }) + }) + + describe('acts similar to Microsoft Excel', () => { + /** + * Examples from + * https://support.microsoft.com/en-us/office/xlookup-function-b7fd680e-6d10-43e6-84f9-88eae8bf5929 + */ + + it('should find value in simple column range (official example 1)', () => { + const engine = HyperFormula.buildFromArray([ + ['China', 'CN'], + ['India', 'IN'], + ['United States', 'US'], + ['Indonesia', 'ID'], + ['France', 'FR'], + ['=XLOOKUP("Indonesia", A1:A5, B1:B5)'], + ]) + + expect(engine.getCellValue(adr('A6'))).toEqual('ID') + }) + + it('should find row range in table (official example 2)', () => { + const engine = HyperFormula.buildFromArray([ + ['8389', 'Dianne Pugh', 'Finance'], + ['4390', 'Ned Lanning', 'Marketing'], + ['8604', 'Margo Hendrix', 'Sales'], + ['8389', 'Dianne Pugh', 'Finance'], + ['4937', 'Earlene McCarty', 'Accounting'], + ['=XLOOKUP(A1, A2:A5, B2:C5)'], + ]) + + expect(engine.getRangeValues(AbsoluteCellRange.spanFrom(adr('A6'), 2, 1))).toEqual([['Dianne Pugh', 'Finance']]) + }) + + it('should find column range in table (official example 2, transposed)', () => { + const engine = HyperFormula.buildFromArray([ + ['8389', '4390', '8604', '8389', '4937'], + ['Dianne Pugh', 'Ned Lanning', 'Margo Hendrix', 'Dianne Pugh', 'Earlene McCarty'], + ['Finance', 'Marketing', 'Sales', 'Finance', 'Accounting'], + ['=XLOOKUP(A1, B1:E1, B2:E3)'], + [] + ]) + + expect(engine.getRangeValues(AbsoluteCellRange.spanFrom(adr('A4'), 1, 2))).toEqual([['Dianne Pugh'], ['Finance']]) + }) + + it('should find use if_not_found argument if not found (official example 3)', () => { + const engine = HyperFormula.buildFromArray([ + ['1234', 'Dianne Pugh', 'Finance'], + ['4390', 'Ned Lanning', 'Marketing'], + ['8604', 'Margo Hendrix', 'Sales'], + ['8389', 'Dianne Pugh', 'Finance'], + ['4937', 'Earlene McCarty', 'Accounting'], + ['=XLOOKUP(A1, A2:A5, B2:B5, "ID not found")'], + ]) + + expect(engine.getCellValue(adr('A6'))).toEqual('ID not found') + }) + + it('example 4', () => { + const engine = HyperFormula.buildFromArray([ + ['10', 'a'], + ['20', 'b'], + ['30', 'c'], + ['40', 'd'], + ['50', 'e'], + ['=XLOOKUP(25, A1:A5, B1:B5, 0, 1, 1)'], + ]) + + expect(engine.getCellValue(adr('A6'))).toEqual('c') + }) + + it('nested xlookup function to perform both a vertical and horizontal match (official example 5)', () => { + const engine = HyperFormula.buildFromArray([ + ['Quarter', 'Gross profit', 'Net profit', 'Profit %'], + ['Qtr1', '=XLOOKUP(B1, $A4:$A12, XLOOKUP($A2, $B3:$F3, $B4:$F12))', '19342', '29.3'], + ['Income statement', 'Qtr1', 'Qtr2', 'Qtr3', 'Qtr4', 'Total'], + ['Total sales', '50000', '78200', '89500', '91200', '308950'], + ['Cost of sales', '25000', '42050', '59450', '60450', '186950'], + ['Gross profit', '25000', '36150', '30050', '30800', '122000'], + ['Depreciation', '899', '791', '202', '412', '2304'], + ['Interest', '513', '853', '150', '956', '2472'], + ['Earnings before tax', '23588', '34506', '29698', '29432', '117224'], + ['Tax', '4246', '6211', '5346', '5298', '21100'], + ['Net profit', '19342', '28295', '24352', '24134', '96124'], + ['Profit %', '29.3', '27.8', '23.4', '27.6', '26.9'], + ]) + + expect(engine.getCellValue(adr('B2'))).toEqual(25000) + }) + }) +})