diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a3af8b55..a4585b549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added a new function: IRR. [#1591](https://github.com/handsontable/hyperformula/issues/1591) - Added a new function: N. [#1585](https://github.com/handsontable/hyperformula/issues/1585) +- Added a new function: VALUE. [#1592](https://github.com/handsontable/hyperformula/issues/1592) ## [3.1.1] - 2025-12-18 diff --git a/docs/guide/built-in-functions.md b/docs/guide/built-in-functions.md index 0847d17ff..dd9777523 100644 --- a/docs/guide/built-in-functions.md +++ b/docs/guide/built-in-functions.md @@ -506,6 +506,7 @@ Total number of functions: **{{ $page.functionsCount }}** | UNICHAR | Returns the character created by using provided code point. | UNICHAR(Number) | | UNICODE | Returns the Unicode code point of a first character of a text. | UNICODE(Text) | | UPPER | Returns text converted to uppercase. | UPPER(Text) | +| VALUE | Parses a number, date, time, datetime, currency, or percentage from a text string. | VALUE(Text) | [^non-odff]: The return value of this function is compliant with the diff --git a/package.json b/package.json index ca27ce7fc..230f7ab6c 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "test": "npm-run-all lint test:unit test:browser test:compatibility", "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:tdd": "cross-env NODE_ICU_DATA=node_modules/full-icu jest --watch adding-sheet", + "test:tdd": "cross-env NODE_ICU_DATA=node_modules/full-icu jest --watch function-value", "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/NumberLiteralHelper.ts b/src/NumberLiteralHelper.ts index 84aff54b7..8d7a526ce 100644 --- a/src/NumberLiteralHelper.ts +++ b/src/NumberLiteralHelper.ts @@ -16,7 +16,7 @@ export class NumberLiteralHelper { const thousandSeparator = this.config.thousandSeparator === '.' ? `\\${this.config.thousandSeparator}` : this.config.thousandSeparator const decimalSeparator = this.config.decimalSeparator === '.' ? `\\${this.config.decimalSeparator}` : this.config.decimalSeparator - this.numberPattern = new RegExp(`^([+-]?((${decimalSeparator}\\d+)|(\\d+(${thousandSeparator}\\d{3,})*(${decimalSeparator}\\d*)?)))(e[+-]?\\d+)?$`) + this.numberPattern = new RegExp(`^([+-]?((${decimalSeparator}\\d+)|(\\d+(${thousandSeparator}\\d{3,})*(${decimalSeparator}\\d*)?)))([eE][+-]?\\d+)?$`) this.allThousandSeparatorsRegex = new RegExp(`${thousandSeparator}`, 'g') } diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index febe6867b..d0ed619fb 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { UNICHAR: 'UNICHAR', UNICODE: 'UNICODE', UPPER: 'VELKÁ', + VALUE: 'HODNOTA', VARA: 'VARA', 'VAR.P': 'VAR.P', VARPA: 'VARPA', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 6ffd42bac..d8838ff19 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { UNICHAR: 'UNICHAR', UNICODE: 'UNICODE', UPPER: 'STORE.BOGSTAVER', + VALUE: 'VÆRDI', VARA: 'VARIANSV', 'VAR.P': 'VARIANS.P', VARPA: 'VARIANSPV', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index 55ff125bb..d05f7dcdd 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { UNICHAR: 'UNIZEICHEN', UNICODE: 'UNICODE', UPPER: 'GROSS', + VALUE: 'WERT', VARA: 'VARIANZA', 'VAR.P': 'VAR.P', VARPA: 'VARIANZENA', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index 6603d21af..233a354da 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -227,6 +227,7 @@ const dictionary: RawTranslationPackage = { UNICHAR: 'UNICHAR', UNICODE: 'UNICODE', UPPER: 'UPPER', + VALUE: 'VALUE', VARA: 'VARA', 'VAR.P': 'VAR.P', VARPA: 'VARPA', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index fb40a03ac..7593a79ed 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -225,6 +225,7 @@ export const dictionary: RawTranslationPackage = { UNICHAR: 'UNICHAR', UNICODE: 'UNICODE', UPPER: 'MAYUSC', + VALUE: 'VALOR', VARA: 'VARA', 'VAR.P': 'VAR.P', VARPA: 'VARPA', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index 7b1fdb584..25d54032a 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { UNICHAR: 'UNICODEMERKKI', UNICODE: 'UNICODE', UPPER: 'ISOT', + VALUE: 'ARVO', VARA: 'VARA', 'VAR.P': 'VAR.P', VARPA: 'VARPA', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index 2f811ad95..7733d20b4 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { UNICHAR: 'UNICAR', UNICODE: 'UNICODE', UPPER: 'MAJUSCULE', + VALUE: 'CNUM', VARA: 'VARA', 'VAR.P': 'VAR.P.N', VARPA: 'VARPA', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index e992f97ef..d1341fb21 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { UNICHAR: 'UNIKARAKTER', UNICODE: 'UNICODE', UPPER: 'NAGYBETŰS', + VALUE: 'ÉRTÉK', VARA: 'VARA', 'VAR.P': 'VAR.S', VARPA: 'VARPA', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 6659d5439..28f4ece50 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { UNICHAR: 'CARATT.UNI', UNICODE: 'UNICODE', UPPER: 'MAIUSC', + VALUE: 'VALORE', VARA: 'VAR.VALORI', 'VAR.P': 'VAR.P', VARPA: 'VAR.POP.VALORI', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index 729fe1042..61ec76ccf 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { UNICHAR: 'UNICODETEGN', UNICODE: 'UNICODE', UPPER: 'STORE', + VALUE: 'VERDI', VARA: 'VARIANSA', 'VAR.P': 'VARIANS.P', VARPA: 'VARIANSPA', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index 0407a479f..4de49b7a4 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { UNICHAR: 'UNITEKEN', UNICODE: 'UNICODE', UPPER: 'HOOFDLETTERS', + VALUE: 'WAARDE', VARA: 'VARA', 'VAR.P': 'VAR.P', VARPA: 'VARPA', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index 3d6a941d5..7c0a08756 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { UNICHAR: 'ZNAK.UNICODE', UNICODE: 'UNICODE', UPPER: 'LITERY.WIELKIE', + VALUE: 'WARTOŚĆ', VARA: 'WARIANCJA.A', 'VAR.P': 'WARIANCJA.POP', VARPA: 'WARIANCJA.POPUL.A', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index 1679748c7..cd3fc715d 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { UNICHAR: 'CARACTUNI', UNICODE: 'UNICODE', UPPER: 'MAIÚSCULA', + VALUE: 'VALOR', VARA: 'VARA', 'VAR.P': 'VAR.P', VARPA: 'VARPA', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index 69a56fd8f..66032d3cd 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { UNICHAR: 'ЮНИСИМВ', UNICODE: 'UNICODE', UPPER: 'ПРОПИСН', + VALUE: 'ЗНАЧ', VARA: 'ДИСПА', 'VAR.P': 'ДИСП.Г', VARPA: 'ДИСПРА', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index ceaf0649d..d4741d6fc 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { UNICHAR: 'UNICHAR', UNICODE: 'UNICODE', UPPER: 'VERSALER', + VALUE: 'TEXTNUM', VARA: 'VARA', 'VAR.P': 'VARIANS.P', VARPA: 'VARPA', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index 3255b0a7d..38507c24b 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { UNICHAR: 'UNICODEKARAKTERİ', UNICODE: 'UNICODE', UPPER: 'BÜYÜKHARF', + VALUE: 'DEĞER', VARA: 'VARA', 'VAR.P': 'VAR.P', VARPA: 'VARSA', diff --git a/src/interpreter/plugin/TextPlugin.ts b/src/interpreter/plugin/TextPlugin.ts index c9802828b..4bb5832df 100644 --- a/src/interpreter/plugin/TextPlugin.ts +++ b/src/interpreter/plugin/TextPlugin.ts @@ -5,10 +5,11 @@ import {CellError, ErrorType} from '../../Cell' import {ErrorMessage} from '../../error-message' +import {Maybe} from '../../Maybe' import {ProcedureAst} from '../../parser' import {InterpreterState} from '../InterpreterState' -import {InternalScalarValue, InterpreterValue, RawScalarValue} from '../InterpreterValue' import {SimpleRangeValue} from '../../SimpleRangeValue' +import {ExtendedNumber, InterpreterValue, isExtendedNumber, RawScalarValue, InternalScalarValue} from '../InterpreterValue' import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' /** @@ -149,6 +150,12 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec {argumentType: FunctionArgumentType.STRING} ] }, + 'VALUE': { + method: 'value', + parameters: [ + {argumentType: FunctionArgumentType.SCALAR} + ] + }, } /** @@ -156,8 +163,8 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec * * Concatenates provided arguments to one string. * - * @param ast - * @param state + * @param {ProcedureAst} ast - The procedure AST node + * @param {InterpreterState} state - The interpreter state */ public concatenate(ast: ProcedureAst, state: InterpreterState): InterpreterValue { return this.runFunction(ast.args, state, this.metadata('CONCATENATE'), (...args) => { @@ -170,8 +177,8 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec * * Splits provided string using space separator and returns chunk at zero-based position specified by second argument * - * @param ast - * @param state + * @param {ProcedureAst} ast - The procedure AST node + * @param {InterpreterState} state - The interpreter state */ public split(ast: ProcedureAst, state: InterpreterState): InterpreterValue { return this.runFunction(ast.args, state, this.metadata('SPLIT'), (stringToSplit: string, indexToUse: number) => { @@ -376,6 +383,63 @@ export class TextPlugin extends FunctionPlugin implements FunctionPluginTypechec }) } + /** + * Corresponds to VALUE(text) + * + * Converts a text string that represents a number to a number. + * + * @param {ProcedureAst} ast - The procedure AST node + * @param {InterpreterState} state - The interpreter state + */ + public value(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('VALUE'), (arg: RawScalarValue): ExtendedNumber | CellError => { + if (arg instanceof CellError) { + return arg + } + + if (isExtendedNumber(arg)) { + return arg + } + + if (typeof arg !== 'string') { + return new CellError(ErrorType.VALUE, ErrorMessage.NumberCoercion) + } + + const trimmedArg = arg.trim() + + const parenthesesMatch = /^\(([^()]+)\)$/.exec(trimmedArg) + if (parenthesesMatch) { + const innerValue = this.parseStringToNumber(parenthesesMatch[1]) + if (innerValue !== undefined) { + return -innerValue + } + } + + const parsedValue = this.parseStringToNumber(trimmedArg) + if (parsedValue !== undefined) { + return parsedValue + } + + return new CellError(ErrorType.VALUE, ErrorMessage.NumberCoercion) + }) + } + + /** + * Parses a string to a numeric value, handling whitespace trimming and empty string validation. + * + * @param {string} input - The string to parse + * @returns {Maybe} The parsed number or undefined if parsing fails or input is empty + */ + private parseStringToNumber(input: string): Maybe { + const trimmedInput = input.trim() + + if (trimmedInput === '') { + return undefined + } + + return this.arithmeticHelper.coerceToMaybeNumber(trimmedInput) + } + private escapeRegExpSpecialCharacters(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } diff --git a/test/unit/interpreter/function-value.spec.ts b/test/unit/interpreter/function-value.spec.ts new file mode 100644 index 000000000..0687f3c70 --- /dev/null +++ b/test/unit/interpreter/function-value.spec.ts @@ -0,0 +1,451 @@ +import {CellValueDetailedType, ErrorType, HyperFormula} from '../../../src' +import {ErrorMessage} from '../../../src/error-message' +import {adr, detailedError} from '../testUtils' + +describe('Function VALUE', () => { + describe('argument validation', () => { + it('should return error for wrong number of arguments', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE()'], + ['=VALUE("1", "2")'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.NA, ErrorMessage.WrongArgNumber)) + expect(engine.getCellValue(adr('A2'))).toEqualError(detailedError(ErrorType.NA, ErrorMessage.WrongArgNumber)) + }) + }) + + describe('basic numeric string conversion', () => { + it('should convert integer string', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("123")'], + ]) + + expect(engine.getCellValue(adr('A1'))).toBe(123) + }) + + it('should convert decimal string', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("123.45")'], + ]) + + expect(engine.getCellValue(adr('A1'))).toBe(123.45) + }) + + it('should convert negative number string', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("-123")'], + ]) + + expect(engine.getCellValue(adr('A1'))).toBe(-123) + }) + + it('should convert string with plus sign', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("+123")'], + ]) + + expect(engine.getCellValue(adr('A1'))).toBe(123) + }) + + it('should trim leading and trailing spaces', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE(" 123 ")'], + ]) + + expect(engine.getCellValue(adr('A1'))).toBe(123) + }) + + it('should convert string with leading zeros', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("00123")'], + ]) + + expect(engine.getCellValue(adr('A1'))).toBe(123) + }) + + it('should convert scientific notation (uppercase E)', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("1.23E3")'], + ]) + + expect(engine.getCellValue(adr('A1'))).toBe(1230) + }) + + it('should convert scientific notation (lowercase e, negative exponent)', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("1.5e-2")'], + ]) + + expect(engine.getCellValue(adr('A1'))).toBe(0.015) + }) + + it('should convert string with thousand separator', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("1,234")'], + ], {thousandSeparator: ',', functionArgSeparator: ';'}) + + expect(engine.getCellValue(adr('A1'))).toBe(1234) + }) + + it('should convert parentheses notation as negative number', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("(123)")'], + ]) + + expect(engine.getCellValue(adr('A1'))).toBe(-123) + }) + + it('should return VALUE error for nested parentheses', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("((123))")'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.NumberCoercion)) + }) + + it('should return VALUE error for unbalanced parentheses', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("(123")'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.NumberCoercion)) + }) + }) + + describe('percentage strings', () => { + it('should convert percentage string to decimal', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("50%")'], + ]) + + expect(engine.getCellValue(adr('A1'))).toBe(0.5) + }) + }) + + describe('currency strings', () => { + it('should convert currency string', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("$123")'], + ]) + + expect(engine.getCellValue(adr('A1'))).toBe(123) + }) + + it('should convert currency string with thousand separator and decimal', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("$1,234.56")'], + ], {thousandSeparator: ',', functionArgSeparator: ';'}) + + expect(engine.getCellValue(adr('A1'))).toBe(1234.56) + }) + }) + + describe('date strings', () => { + it('should convert date string to serial number', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("01/13/2026")'], + ], {dateFormats: ['MM/DD/YYYY']}) + + expect(engine.getCellValue(adr('A1'))).toBe(46035) + }) + }) + + describe('time strings', () => { + it('should convert time string to fraction of day', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("14:30")'], + ]) + + expect(engine.getCellValue(adr('A1'))).toBeCloseTo(0.60416667, 6) + }) + + it('should convert time string with seconds', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("12:30:45")'], + ]) + + expect(engine.getCellValue(adr('A1'))).toBeCloseTo(0.52135417, 6) + }) + + it('should handle time greater than 24 hours', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("25:00")'], + ]) + + expect(engine.getCellValue(adr('A1'))).toBeCloseTo(1.04166667, 6) + }) + }) + + describe('datetime strings', () => { + it('should convert datetime string to serial number with time fraction', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("01/13/2026 14:30")'], + ], {dateFormats: ['MM/DD/YYYY']}) + + expect(engine.getCellValue(adr('A1'))).toBeCloseTo(46035.60417, 4) + }) + }) + + describe('error cases', () => { + it('should return VALUE error for empty string', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("")'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.NumberCoercion)) + }) + + it('should return VALUE error for non-numeric string', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("abc")'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.NumberCoercion)) + }) + + it('should return VALUE error for string with trailing text', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("123abc")'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.NumberCoercion)) + }) + + it('should return VALUE error for European decimal format in default locale', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("123,45")'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.NumberCoercion)) + }) + + }) + + describe('12-hour time format', () => { + it('should parse 12-hour time format with am/pm', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("3:00pm")'], + ]) + + expect(engine.getCellValue(adr('A1'))).toBeCloseTo(0.625, 6) + }) + }) + + describe('type coercion', () => { + it('should return VALUE error for boolean input', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE(TRUE())'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.NumberCoercion)) + }) + + it('should pass through number input unchanged', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE(123)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toBe(123) + }) + + it('should propagate errors', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE(1/0)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.DIV_BY_ZERO)) + }) + + it('should convert cell reference with string value', () => { + const engine = HyperFormula.buildFromArray([ + ["'123", '=VALUE(A1)'], + ]) + + expect(engine.getCellValue(adr('B1'))).toBe(123) + }) + + it('should pass through cell reference with numeric value', () => { + const engine = HyperFormula.buildFromArray([ + [123, '=VALUE(A1)'], + ]) + + expect(engine.getCellValue(adr('B1'))).toBe(123) + }) + }) + + describe('locale-specific behavior', () => { + it('should respect decimal separator config', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("123,45")'], + ], {decimalSeparator: ',', thousandSeparator: ' ', functionArgSeparator: ';'}) + + expect(engine.getCellValue(adr('A1'))).toBe(123.45) + }) + + it('should respect thousand separator config', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("1 234,56")'], + ], {decimalSeparator: ',', thousandSeparator: ' ', functionArgSeparator: ';'}) + + expect(engine.getCellValue(adr('A1'))).toBe(1234.56) + }) + + it('should respect custom currency symbol', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("€123")'], + ], {currencySymbol: ['€']}) + + expect(engine.getCellValue(adr('A1'))).toBe(123) + }) + + it('should handle currency symbol at end', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("123€")'], + ], {currencySymbol: ['€']}) + + expect(engine.getCellValue(adr('A1'))).toBe(123) + }) + }) + + describe('custom parseDateTime', () => { + it('should use custom parseDateTime function for date parsing', () => { + const customParseDateTime = jasmine.createSpy().and.callFake((dateString: string) => { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateString) + if (match) { + return { + year: parseInt(match[1], 10), + month: parseInt(match[2], 10), + day: parseInt(match[3], 10), + } + } + return undefined + }) + + const engine = HyperFormula.buildFromArray([ + ['=VALUE("2026-01-13")'], + ], { + parseDateTime: customParseDateTime, + dateFormats: ['YYYY-MM-DD'], + }) + + expect(customParseDateTime).toHaveBeenCalledWith('2026-01-13', 'YYYY-MM-DD', 'hh:mm') + expect(engine.getCellValue(adr('A1'))).toBe(46035) + }) + + it('should use custom parseDateTime function for time parsing', () => { + const customParseDateTime = jasmine.createSpy().and.callFake((dateString: string) => { + const match = /^(\d{1,2})h(\d{2})m$/.exec(dateString) + if (match) { + return { + hours: parseInt(match[1], 10), + minutes: parseInt(match[2], 10), + seconds: 0, + } + } + return undefined + }) + + const engine = HyperFormula.buildFromArray([ + ['=VALUE("14h30m")'], + ], { + parseDateTime: customParseDateTime, + timeFormats: ['hh:mm'], + }) + + expect(customParseDateTime).toHaveBeenCalledWith('14h30m', 'DD/MM/YYYY', 'hh:mm') + expect(engine.getCellValue(adr('A1'))).toBeCloseTo(0.60416667, 6) + }) + + it('should use custom parseDateTime function for datetime parsing', () => { + const customParseDateTime = jasmine.createSpy().and.callFake((dateString: string) => { + const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(dateString) + if (match) { + return { + year: parseInt(match[1], 10), + month: parseInt(match[2], 10), + day: parseInt(match[3], 10), + hours: parseInt(match[4], 10), + minutes: parseInt(match[5], 10), + seconds: 0, + } + } + return undefined + }) + + const engine = HyperFormula.buildFromArray([ + ['=VALUE("2026-01-13T14:30")'], + ], { + parseDateTime: customParseDateTime, + dateFormats: ['YYYY-MM-DD'], + timeFormats: ['hh:mm'], + }) + + expect(customParseDateTime).toHaveBeenCalledWith('2026-01-13T14:30', 'YYYY-MM-DD', 'hh:mm') + expect(engine.getCellValue(adr('A1'))).toBeCloseTo(46035.60417, 4) + }) + + it('should return VALUE error when custom parseDateTime returns undefined', () => { + const customParseDateTime = jasmine.createSpy().and.returnValue(undefined) + + const engine = HyperFormula.buildFromArray([ + ['=VALUE("invalid-format")'], + ], { + parseDateTime: customParseDateTime, + }) + + expect(customParseDateTime).toHaveBeenCalledWith('invalid-format', 'DD/MM/YYYY', 'hh:mm') + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.NumberCoercion)) + }) + }) + + describe('return type', () => { + it('should return NUMBER_RAW for numeric string', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("123")'], + ]) + + expect(engine.getCellValueDetailedType(adr('A1'))).toBe(CellValueDetailedType.NUMBER_RAW) + }) + + it('should return NUMBER_DATE for date string', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("01/13/2026")'], + ], {dateFormats: ['MM/DD/YYYY']}) + + expect(engine.getCellValueDetailedType(adr('A1'))).toBe(CellValueDetailedType.NUMBER_DATE) + }) + + it('should return NUMBER_TIME for time string', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("14:30")'], + ]) + + expect(engine.getCellValueDetailedType(adr('A1'))).toBe(CellValueDetailedType.NUMBER_TIME) + }) + + it('should return NUMBER_DATETIME for datetime string', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("01/13/2026 14:30")'], + ], {dateFormats: ['MM/DD/YYYY']}) + + expect(engine.getCellValueDetailedType(adr('A1'))).toBe(CellValueDetailedType.NUMBER_DATETIME) + }) + + it('should return NUMBER_PERCENT for percentage string', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("50%")'], + ]) + + expect(engine.getCellValueDetailedType(adr('A1'))).toBe(CellValueDetailedType.NUMBER_PERCENT) + }) + + it('should return NUMBER_CURRENCY for currency string', () => { + const engine = HyperFormula.buildFromArray([ + ['=VALUE("$123")'], + ]) + + expect(engine.getCellValueDetailedType(adr('A1'))).toBe(CellValueDetailedType.NUMBER_CURRENCY) + }) + }) +})