diff --git a/docs/guide/built-in-functions.md b/docs/guide/built-in-functions.md index 1ed8e18fb..01945e5e6 100644 --- a/docs/guide/built-in-functions.md +++ b/docs/guide/built-in-functions.md @@ -227,6 +227,7 @@ Total number of functions: **{{ $page.functionsCount }}** | 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 | The XLOOKUP function searches a range or an array, and then returns the item corresponding to the first match it finds. If no match exists, then XLOOKUP can return the closest (approximate) match. Current limitations: only default match_mode and search_mode are supported, a range of value is returned, not a range, so having XLOOKUP(...):XLOOKUP(...) will not work. | XLOOKUP(lookup_value, lookup_array, return_array, [if_not_found], [match_mode], [search_mode]) | ### Math and trigonometry diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index 1ff989648..c96292bad 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 54fd85082..36406c12d 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 354e0fa1f..1c4937f0d 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 2f01c8271..1572200d2 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 7b31d3845..e287a751f 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 b7fdc7735..b851a3e89 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 d3721471d..82a2de12f 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 8bc529d76..538795a7e 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 43950a0ef..40972ee0f 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 2df0ca0b8..480af8bca 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 14800e6c5..d1434094f 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 8ce39a6d9..706db6caa 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 2c41911a9..73786288b 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 9f4fbc3cd..a53478453 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 bd49af139..d6276be2d 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 f6f2ca32b..2c7e8b285 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/plugin/LookupPlugin.ts b/src/interpreter/plugin/LookupPlugin.ts index 0b33a89cf..6f493e702 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 }, ] }, } @@ -94,6 +113,72 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech }) } + /** + * 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, ifNotFound: any, matchMode: number, searchMode: number) => { + if (![0, -1, 1, 2].includes(matchMode)) { + return new CellError(ErrorType.VALUE, ErrorMessage.BadMode) + } + + if (![1, -1, 1, 2].includes(searchMode)) { + return new CellError(ErrorType.VALUE, ErrorMessage.BadMode) + } + + if (matchMode !== 0) { + // not supported yet + // TODO: Implement match mode + return new CellError(ErrorType.VALUE, ErrorMessage.BadMode) + } + + if (searchMode !== 1) { + // not supported yet + // TODO: Implement search mode + 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) + + return this.doXlookup(zeroIfEmpty(key), lookupRange, returnRange, ifNotFound, matchMode, searchMode) + }) + } + + 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) @@ -101,6 +186,8 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech } protected searchInRange(key: RawNoErrorScalarValue, range: SimpleRangeValue, sorted: boolean, searchStrategy: SearchStrategy): number { + // for sorted option: use findInOrderedArray + if (!sorted && typeof key === 'string' && this.arithmeticHelper.requiresRegex(key)) { return searchStrategy.advancedFind( this.arithmeticHelper.eqMatcherFunction(key), @@ -171,6 +258,25 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech return value } + private doXlookup(key: RawNoErrorScalarValue, lookupRange: SimpleRangeValue, returnRange: SimpleRangeValue, ifNotFound: any, matchMode: number, searchMode: number): 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, false, searchStrategy) + + if (indexFound === -1) { + return (ifNotFound == ErrorType.NA) ? new CellError(ErrorType.NA, ErrorMessage.ValueNotFound) : ifNotFound + } + + 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) diff --git a/test/interpreter/function-xlookup.spec.ts b/test/interpreter/function-xlookup.spec.ts new file mode 100644 index 000000000..6bc7b15f8 --- /dev/null +++ b/test/interpreter/function-xlookup.spec.ts @@ -0,0 +1,402 @@ +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 (verical 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') + }) + + describe('when lookupArray is a single-cell range', () => { + it('works 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('works 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('works 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('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], + ]) + + 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] + ]) + + expect(engine.getCellValue(adr('A1'))).toEqual(2) + }) + + 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], + ]) + + 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] + ]) + + expect(engine.getCellValue(adr('A1'))).toEqual(4) + }) + + 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], + ]) + + 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], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqual(2) + }) + + 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], + ]) + + 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], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqual(2) + }) + }) + + 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) + }) + }) +}) + +// TODO: +// - implement modes +// - debugger