diff --git a/v3/src/models/formula/functions/date-functions.test.ts b/v3/src/models/formula/functions/date-functions.test.ts index 63f7bca30..6618f9369 100644 --- a/v3/src/models/formula/functions/date-functions.test.ts +++ b/v3/src/models/formula/functions/date-functions.test.ts @@ -1,4 +1,5 @@ import { formatDate } from "../../../utilities/date-utils" +import { UNDEF_RESULT } from "./function-utils" import { math } from "./math" describe("date", () => { @@ -55,3 +56,154 @@ describe("date", () => { expect(fn2.evaluate()).toEqual(formatDate(new Date(2020, 4, 1))) }) }) + +describe("year", () => { + it("returns the year of the provided date object", () => { + const fn = math.compile("year(date(2020, 2, 3))") + expect(fn.evaluate()).toEqual(2020) + }) + + it("returns the year of the provided date string", () => { + const fn = math.compile("year('2020-02-03')") + expect(fn.evaluate()).toEqual(2020) + }) + + it("returns undefined if the date is incorrect", () => { + const fn = math.compile("year(null)") + expect(fn.evaluate()).toEqual(UNDEF_RESULT) + }) +}) + +describe("month", () => { + it("returns the month of the provided date object", () => { + const fn = math.compile("month(date(2020, 2, 3))") + expect(fn.evaluate()).toEqual(2) + }) + + it("returns the month of the provided date string", () => { + const fn = math.compile("month('2020-02-03')") + expect(fn.evaluate()).toEqual(2) + }) + + it("returns undefined if the date is incorrect", () => { + const fn = math.compile("month(null)") + expect(fn.evaluate()).toEqual(UNDEF_RESULT) + }) +}) + +describe("monthName", () => { + it("returns the month name of the provided date object", () => { + const fn = math.compile("monthName(date(2020, 2, 3))") + expect(fn.evaluate()).toEqual("February") + }) + + it("returns the month name of the provided date string", () => { + const fn = math.compile("monthName('2020-02-03')") + expect(fn.evaluate()).toEqual("February") + }) + + it("returns undefined if the date is incorrect", () => { + const fn = math.compile("monthName(null)") + expect(fn.evaluate()).toEqual(UNDEF_RESULT) + }) +}) + +describe("dayOfMonth", () => { + it("returns the day of the month of the provided date object", () => { + const fn = math.compile("dayOfMonth(date(2020, 2, 3))") + expect(fn.evaluate()).toEqual(3) + }) + + it("returns the day of the month of the provided date string", () => { + const fn = math.compile("dayOfMonth('2020-02-03')") + expect(fn.evaluate()).toEqual(3) + }) + + it("returns undefined if the date is incorrect", () => { + const fn = math.compile("dayOfMonth(null)") + expect(fn.evaluate()).toEqual(UNDEF_RESULT) + }) +}) + +describe("dayOfWeek", () => { + it("returns the day of the week of the provided date object", () => { + const fn = math.compile("dayOfWeek(date(2020, 2, 3))") + expect(fn.evaluate()).toEqual(2) // Sunday is 1, Monday is 2 + }) + + it("returns the day of the week of the provided date string", () => { + const fn = math.compile("dayOfWeek('2020-02-03')") + expect(fn.evaluate()).toEqual(2) // Sunday is 1, Monday is 2 + }) + + it("returns undefined if the date is incorrect", () => { + const fn = math.compile("dayOfWeek(null)") + expect(fn.evaluate()).toEqual(UNDEF_RESULT) + }) +}) + +describe("dayOfWeekName", () => { + it("returns the day of the week name of the provided date object", () => { + const fn = math.compile("dayOfWeekName(date(2020, 2, 3))") + expect(fn.evaluate()).toEqual("Monday") + }) + + it("returns the day of the week name of the provided date string", () => { + const fn = math.compile("dayOfWeekName('2020-02-03')") + expect(fn.evaluate()).toEqual("Monday") + }) + + it("returns undefined if the date is incorrect", () => { + const fn = math.compile("dayOfWeekName(null)") + expect(fn.evaluate()).toEqual(UNDEF_RESULT) + }) +}) + +describe("hours", () => { + it("returns the hours of the provided date object", () => { + const fn = math.compile("hours(date(2020, 2, 3, 4, 5, 6, 7))") + expect(fn.evaluate()).toEqual(4) + }) + + it("returns the hours of the provided date string", () => { + const fn = math.compile("hours('2020-02-03T04:05:06.007Z')") + expect(fn.evaluate()).toEqual(4) + }) + + it("returns undefined if the date is incorrect", () => { + const fn = math.compile("hours(null)") + expect(fn.evaluate()).toEqual(UNDEF_RESULT) + }) +}) + +describe("minutes", () => { + it("returns the minutes of the provided date object", () => { + const fn = math.compile("minutes(date(2020, 2, 3, 4, 5, 6, 7))") + expect(fn.evaluate()).toEqual(5) + }) + + it("returns the minutes of the provided date string", () => { + const fn = math.compile("minutes('2020-02-03T04:05:06.007Z')") + expect(fn.evaluate()).toEqual(5) + }) + + it("returns undefined if the date is incorrect", () => { + const fn = math.compile("minutes(null)") + expect(fn.evaluate()).toEqual(UNDEF_RESULT) + }) +}) + +describe("now", () => { + it("returns the current date", () => { + const fn = math.compile("now()") + expect(fn.evaluate()).toEqual(formatDate(new Date())) + }) +}) + +describe("today", () => { + it("returns the current date without time", () => { + const fn = math.compile("today()") + const now = new Date() + expect(fn.evaluate()).toEqual(formatDate(new Date(now.getFullYear(), now.getMonth(), now.getDate()))) + }) +}) diff --git a/v3/src/models/formula/functions/date-functions.ts b/v3/src/models/formula/functions/date-functions.ts index 96ea1a572..e12d5d3bb 100644 --- a/v3/src/models/formula/functions/date-functions.ts +++ b/v3/src/models/formula/functions/date-functions.ts @@ -1,56 +1,120 @@ import { FValue } from "../formula-types" import { UNDEF_RESULT } from "./function-utils" -import { formatDate } from "../../../utilities/date-utils" -import { fixYear } from "../../../utilities/date-parser" - -/** - Returns true if the specified value should be treated as epoch - seconds when provided as the only argument to the date() function, - false if the value should be treated as a year. - date(2000) should be treated as a year, but date(12345) should not. - */ -export function defaultToEpochSecs(iValue: number) { - return Math.abs(iValue) >= 5000 -} +import { convertToDate, createDate, formatDate } from "../../../utilities/date-utils" +import { t } from "../../../utilities/translation/translate" -function formatDateWithUndefFallback(date: Date) { - return formatDate(date) || UNDEF_RESULT +function formatDateWithUndefFallback(date: Date | null) { + return formatDate(date) ?? UNDEF_RESULT } export const dateFunctions = { date: { numOfRequiredArguments: 1, - evaluate: (...args: FValue[]) => { - if (args.length === 0) { - return formatDateWithUndefFallback(new Date()) - } - - const yearOrSeconds = args[0] != null ? Number(args[0]) : null - - if (args.length === 1 && yearOrSeconds != null && defaultToEpochSecs(yearOrSeconds)) { - // Only one argument and it's a number that should be treated as epoch seconds. - // Convert from seconds to milliseconds. - return formatDateWithUndefFallback(new Date(yearOrSeconds * 1000)) - } - - let year = yearOrSeconds // at this point, yearOrSeconds is always interpreted as a year - const monthIndex = args[1] != null ? Math.max(0, Number(args[1]) - 1) : 0 - const day = args[2] != null ? Number(args[2]) : 1 - const hours = args[3] != null ? Number(args[3]) : 0 - const minutes = args[4] != null ? Number(args[4]) : 0 - const seconds = args[5] != null ? Number(args[5]) : 0 - const milliseconds = args[6] != null ? Number(args[6]) : 0 - - // Logic ported from V2 for backwards compatibility - if (year == null) { - year = new Date().getFullYear() // default to current year - } - // Apply the same interpretation of the year value as the date parser - // (e.g. numbers below 100 are treated as 20xx or 19xx). - year = fixYear(year) - - const date = new Date(year, monthIndex, day, hours, minutes, seconds, milliseconds) - return isNaN(date.valueOf()) ? UNDEF_RESULT : formatDateWithUndefFallback(date) + evaluate: (...args: FValue[]) => formatDateWithUndefFallback(createDate(...args as (string | number)[])) + }, + + year: { + numOfRequiredArguments: 1, + evaluate: (date: FValue) => { + const dateObject = convertToDate(date) + return dateObject ? dateObject.getFullYear() : UNDEF_RESULT } + }, + + month: { + numOfRequiredArguments: 1, + evaluate: (date: FValue) => { + const dateObject = convertToDate(date) + // + 1 to make January 1, February 2, etc. and be backwards compatible with the V2 implementation + return dateObject ? dateObject.getMonth() + 1 : UNDEF_RESULT + } + }, + + monthName: { + numOfRequiredArguments: 1, + evaluate: (date: FValue) => { + const dateObject = convertToDate(date) + const monthNames = [ + 'DG.Formula.DateLongMonthJanuary', + 'DG.Formula.DateLongMonthFebruary', + 'DG.Formula.DateLongMonthMarch', + 'DG.Formula.DateLongMonthApril', + 'DG.Formula.DateLongMonthMay', + 'DG.Formula.DateLongMonthJune', + 'DG.Formula.DateLongMonthJuly', + 'DG.Formula.DateLongMonthAugust', + 'DG.Formula.DateLongMonthSeptember', + 'DG.Formula.DateLongMonthOctober', + 'DG.Formula.DateLongMonthNovember', + 'DG.Formula.DateLongMonthDecember' + ] + // V2 would return the month name in local language, but V3 always returns it in English. + // I think that makes more sense, as otherwise it's not possible to use the result in further calculations. + return dateObject ? t(monthNames[dateObject.getMonth()], { lang: "en" }) : UNDEF_RESULT + } + }, + + dayOfMonth: { + numOfRequiredArguments: 1, + evaluate: (date: FValue) => convertToDate(date)?.getDate() ?? UNDEF_RESULT + }, + + dayOfWeek: { + numOfRequiredArguments: 1, + evaluate: (date: FValue) => { + const dateObject = convertToDate(date) + // + 1 to make Sunday 1, Monday 2, etc. and be backwards compatible with the V2 implementation + return dateObject ? dateObject.getDay() + 1 : UNDEF_RESULT + } + }, + + dayOfWeekName: { + numOfRequiredArguments: 1, + evaluate: (date: FValue) => { + const dateObject = convertToDate(date) + const dayNames = [ + 'DG.Formula.DateLongDaySunday', + 'DG.Formula.DateLongDayMonday', + 'DG.Formula.DateLongDayTuesday', + 'DG.Formula.DateLongDayWednesday', + 'DG.Formula.DateLongDayThursday', + 'DG.Formula.DateLongDayFriday', + 'DG.Formula.DateLongDaySaturday' + ] + // V2 would return the day of the week name in local language, but V3 always returns it in English. + // I think that makes more sense, as otherwise it's not possible to use the result in further calculations. + return dateObject ? t(dayNames[dateObject.getDay()], { lang: "en" }) : UNDEF_RESULT + } + }, + + hours: { + numOfRequiredArguments: 1, + evaluate: (date: FValue) => convertToDate(date)?.getHours() ?? UNDEF_RESULT + }, + + minutes: { + numOfRequiredArguments: 1, + evaluate: (date: FValue) => convertToDate(date)?.getMinutes() ?? UNDEF_RESULT + }, + + // TODO: this revealed an issue in the new implementation (date is converted to string too early), fix it + // when the formatting / storage of dates is refactored. + // seconds: { + // numOfRequiredArguments: 1, + // evaluate: (date: FValue) => convertToDate(date)?.getSeconds() ?? UNDEF_RESULT + // }, + + today: { + numOfRequiredArguments: 0, + evaluate: () => { + const now = new Date() + // eliminate time within the day + return formatDateWithUndefFallback(new Date(now.getFullYear(), now.getMonth(), now.getDate())) + } + }, + + now: { + numOfRequiredArguments: 0, + evaluate: () => formatDateWithUndefFallback(createDate()) } } diff --git a/v3/src/models/formula/functions/function-utils.test.ts b/v3/src/models/formula/functions/function-utils.test.ts index f9d7124cc..b21779a82 100644 --- a/v3/src/models/formula/functions/function-utils.test.ts +++ b/v3/src/models/formula/functions/function-utils.test.ts @@ -1,36 +1,6 @@ -import { isValueNonEmpty, isNumber, isValueTruthy, equal, evaluateNode } from "./function-utils" +import { isValueTruthy, equal, evaluateNode } from "./function-utils" import { parse } from "mathjs" -describe("isValueNonEmpty", () => { - it("should return false for empty values", () => { - expect(isValueNonEmpty("")).toBe(false) - expect(isValueNonEmpty(null)).toBe(false) - expect(isValueNonEmpty(undefined)).toBe(false) - }) - - it("should return true for non-empty values", () => { - expect(isValueNonEmpty("non-empty")).toBe(true) - expect(isValueNonEmpty(0)).toBe(true) - expect(isValueNonEmpty(false)).toBe(true) - }) -}) - -describe("isNumber", () => { - it("should return true for numbers", () => { - expect(isNumber(0)).toBe(true) - expect(isNumber("0")).toBe(true) - expect(isNumber(1.23)).toBe(true) - expect(isNumber("1.23")).toBe(true) - }) - - it("should return false for non-numbers", () => { - expect(isNumber("")).toBe(false) - expect(isNumber("abc")).toBe(false) - expect(isNumber(null)).toBe(false) - expect(isNumber(undefined)).toBe(false) - }) -}) - describe("isValueTruthy", () => { it("should return true for truthy values", () => { expect(isValueTruthy(1)).toBe(true) diff --git a/v3/src/models/formula/functions/function-utils.ts b/v3/src/models/formula/functions/function-utils.ts index 4daa0b195..aa4a811fb 100644 --- a/v3/src/models/formula/functions/function-utils.ts +++ b/v3/src/models/formula/functions/function-utils.ts @@ -1,20 +1,16 @@ import { MathNode } from "mathjs" import { FormulaMathJsScope } from "../formula-mathjs-scope" +import { isValueNonEmpty } from "../../../utilities/math-utils" +export { isNumber, isValueNonEmpty } from "../../../utilities/math-utils" export const UNDEF_RESULT = "" -export const isValueNonEmpty = (value: any) => value !== "" && value !== null && value !== undefined - -// `isNumber` should be consistent within all formula functions. If more advanced parsing is necessary, MathJS -// provides its own `number` helper might be worth considering. However, besides hex number parsing, I haven't found -// any other benefits of using it (and hex numbers support doesn't seem to be necessary). -export const isNumber = (v: any) => isValueNonEmpty(v) && !isNaN(Number(v)) - // CODAP formulas assume that 0 is a truthy value, which is different from default JS behavior. So that, for instance, // count(attribute) will return a count of valid data values, since 0 is a valid numeric value. export const isValueTruthy = (value: any) => isValueNonEmpty(value) && value !== false -export const equal = (a: any, b: any): boolean => { +export const +equal = (a: any, b: any): boolean => { // Checks below might seem redundant once the data set cases start using typed values, but they are not. // Note that user might still compare a string with a number unintentionally, and it makes sense to try to cast // values when possible, so that the comparison can be performed without forcing users to think about types. diff --git a/v3/src/utilities/date-utils.test.ts b/v3/src/utilities/date-utils.test.ts new file mode 100644 index 000000000..4888387ce --- /dev/null +++ b/v3/src/utilities/date-utils.test.ts @@ -0,0 +1,76 @@ +import { convertToDate, createDate } from "./date-utils" + +describe('createDate', () => { + it('returns current date if no arguments are provided', () => { + const date = createDate() + const now = new Date() + expect(date).toBeInstanceOf(Date) + expect(date?.getTime()).toBeCloseTo(now.getTime(), -3) + }) + + it('interprets [0, 50) as 20xx year', () => { + expect(createDate(1)).toEqual(new Date(2001, 0, 1)) + expect(createDate(10)).toEqual(new Date(2010, 0, 1)) + expect(createDate(20)).toEqual(new Date(2020, 0, 1)) + expect(createDate(30)).toEqual(new Date(2030, 0, 1)) + expect(createDate(49)).toEqual(new Date(2049, 0, 1)) + }) + + it('interprets [50, 99] as 19xx year', () => { + expect(createDate(50)).toEqual(new Date(1950, 0, 1)) + expect(createDate(60)).toEqual(new Date(1960, 0, 1)) + expect(createDate(70)).toEqual(new Date(1970, 0, 1)) + expect(createDate(99)).toEqual(new Date(1999, 0, 1)) + }) + + it('interprets number as year if it\'s bigger than 100 but smaller than 5000', () => { + expect(createDate(100)).toEqual(new Date(100, 0, 1)) + expect(createDate(1000)).toEqual(new Date(1000, 0, 1)) + expect(createDate(2000)).toEqual(new Date(2000, 0, 1)) + expect(createDate(4999)).toEqual(new Date(4999, 0, 1)) + }) + + it('interprets number as epoch seconds if it\'s bigger than 5000', () => { + expect(createDate(5000)).toEqual(new Date(5000 * 1000)) + expect(createDate(10000)).toEqual(new Date(10000 * 1000)) + expect(createDate(12345)).toEqual(new Date(12345 * 1000)) + }) + + it('supports month, day, hours, minutes, seconds, and milliseconds arguments', () => { + expect(createDate(2020, 2, 3, 4, 5, 6, 7)).toEqual(new Date(2020, 1, 3, 4, 5, 6, 7)) + expect(createDate(2020, 2, 3)).toEqual(new Date(2020, 1, 3)) + expect(createDate(2020, 2)).toEqual(new Date(2020, 1, 1)) + }) + + it('assumes month is in range 1-12, but 0 is interpreted as January', () => { + expect(createDate(2020, 0, 1)).toEqual(new Date(2020, 0, 1)) + expect(createDate(2020, 5, 1)).toEqual(new Date(2020, 4, 1)) + }) +}) + +describe('convertToDate', () => { + it('returns the date if it\'s already a Date object', () => { + const date = new Date() + expect(convertToDate(date)).toBe(date) + }) + + it('parses the date string', () => { + expect(convertToDate('2020-01-01')).toEqual(new Date(2020, 0, 1)) + }) + + it('parses the number as year when number is smaller than 5000', () => { + expect(convertToDate(2020)).toEqual(new Date(2020, 0, 1)) + expect(convertToDate(4999)).toEqual(new Date(4999, 0, 1)) + }) + + it('parses the number as epoch seconds when number is greater than 5000', () => { + expect(convertToDate(5000)).toEqual(new Date(5000 * 1000)) + expect(convertToDate(12345)).toEqual(new Date(12345 * 1000)) + }) + + it('returns null if the date is invalid', () => { + expect(convertToDate('invalid')).toBeNull() + expect(convertToDate(Infinity)).toBeNull() + expect(convertToDate(null)).toBeNull() + }) +}) diff --git a/v3/src/utilities/date-utils.ts b/v3/src/utilities/date-utils.ts index 712c00dc2..fa866c02c 100644 --- a/v3/src/utilities/date-utils.ts +++ b/v3/src/utilities/date-utils.ts @@ -1,5 +1,5 @@ -import { isDateString } from "./date-parser" -import { goodTickValue, isFiniteNumber } from "./math-utils" +import { fixYear, isDateString, parseDate } from "./date-parser" +import { goodTickValue, isFiniteNumber, isNumber } from "./math-utils" import { getDefaultLanguage } from "./translation/translate" export enum EDateTimeLevel { @@ -109,11 +109,12 @@ export function isDate(iValue: any): iValue is Date { /** * Default formatting for Date objects. - * @param date {Date | number | string } + * @param date {Date | number | string | null } * @param precision {number} * @return {string} */ -export function formatDate(x: Date | number | string, precision: DatePrecision = DatePrecision.None): string | null { +export function formatDate(x: Date | number | string | null, precision: DatePrecision = DatePrecision.None): + string | null { const formatPrecisions: Record = { [DatePrecision.None]: null, [DatePrecision.Year]: { year: 'numeric' }, @@ -148,34 +149,58 @@ export function formatDate(x: Date | number | string, precision: DatePrecision = } /** - Default formatting for Date objects. - Uses toLocaleDateString() for default date formatting. - Optionally uses toLocaleTimeString() for default time formatting. + Returns true if the specified value should be treated as epoch + seconds when provided as the only argument to the date() function, + false if the value should be treated as a year. + date(2000) should be treated as a year, but date(12345) should not. */ -/* -DateUtilities.monthName = function(x) { - if (!(x && (isDate(x) || isDateString(x) || MathUtilities.isFiniteNumber(x)))) return "" - var date - if (isDate(x)) - date = x - else - date = createDate(x) - var monthNames = [ - 'Formula.DateLongMonthJanuary', - 'Formula.DateLongMonthFebruary', - 'Formula.DateLongMonthMarch', - 'Formula.DateLongMonthApril', - 'Formula.DateLongMonthMay', - 'Formula.DateLongMonthJune', - 'Formula.DateLongMonthJuly', - 'Formula.DateLongMonthAugust', - 'Formula.DateLongMonthSeptember', - 'Formula.DateLongMonthOctober', - 'Formula.DateLongMonthNovember', - 'Formula.DateLongMonthDecember' - ], - monthName = monthNames[date.getMonth()] - return monthName && monthName.loc() + export function defaultToEpochSecs(iValue: number) { + return Math.abs(iValue) >= 5000 +} + +export function createDate(...args: (string | number)[]): Date | null { + if (args.length === 0) { + return new Date() + } + + const yearOrSeconds = args[0] != null ? Number(args[0]) : null + + if (args.length === 1 && yearOrSeconds != null && defaultToEpochSecs(yearOrSeconds)) { + // Only one argument and it's a number that should be treated as epoch seconds. + // Convert from seconds to milliseconds. + const dateFromEpoch = new Date(yearOrSeconds * 1000) + return isNaN(dateFromEpoch.valueOf()) ? null : dateFromEpoch + } + + let year = yearOrSeconds // at this point, yearOrSeconds is always interpreted as a year + const monthIndex = args[1] != null ? Math.max(0, Number(args[1]) - 1) : 0 + const day = args[2] != null ? Number(args[2]) : 1 + const hours = args[3] != null ? Number(args[3]) : 0 + const minutes = args[4] != null ? Number(args[4]) : 0 + const seconds = args[5] != null ? Number(args[5]) : 0 + const milliseconds = args[6] != null ? Number(args[6]) : 0 + + // Logic ported from V2 for backwards compatibility + if (year == null) { + year = new Date().getFullYear() // default to current year + } + // Apply the same interpretation of the year value as the date parser + // (e.g. numbers below 100 are treated as 20xx or 19xx). + year = fixYear(year) + + const date = new Date(year, monthIndex, day, hours, minutes, seconds, milliseconds) + return isNaN(date.valueOf()) ? null : date +} + +export function convertToDate(date: any): Date | null { + if (isDate(date)) { + return date + } + if (typeof date === "string" && !isNumber(date)) { + return parseDate(date, true) + } + if (isNumber(date)) { + return createDate(Number(date)) + } + return null } -monthName = DateUtilities.monthName -*/ diff --git a/v3/src/utilities/math-utils.test.ts b/v3/src/utilities/math-utils.test.ts index ac4038bb8..08c0a41ff 100644 --- a/v3/src/utilities/math-utils.test.ts +++ b/v3/src/utilities/math-utils.test.ts @@ -1,5 +1,5 @@ import {FormatLocaleDefinition, format, formatLocale} from "d3-format" -import {between, isFiniteNumber} from "./math-utils" +import {between, isFiniteNumber, isValueNonEmpty, isNumber} from "./math-utils" // default formatting except uses ASCII minus sign const asciiLocale = formatLocale({ minus: "-" } as FormatLocaleDefinition) @@ -25,41 +25,71 @@ describe("math-utils", () => { expect(defaultFormat(-1)).not.toBe("-1") expect(asciiFormat(-1)).toBe("-1") }) + }) + + describe("between", () => { + it("should return true if the value is between the min and max values", () => { + expect(between(1, 0, 2)).toBe(true) + expect(between(0, 0, 2)).toBe(true) + expect(between(2, 0, 2)).toBe(true) + }) + it("should return false if the value is not between the min and max values", () => { + expect(between(-1, 0, 2)).toBe(false) + expect(between(3, 0, 2)).toBe(false) + expect(between(NaN, 0, 2)).toBe(false) + expect(between(Infinity, 0, 2)).toBe(false) + }) + }) + + describe("isFiniteNumber", () => { + it("should return true for finite numbers and false for non-finite numbers", () => { + expect(isFiniteNumber(1)).toBe(true) + expect(isFiniteNumber(1.1)).toBe(true) + expect(isFiniteNumber(0)).toBe(true) + expect(isFiniteNumber(Infinity)).toBe(false) + expect(isFiniteNumber(-Infinity)).toBe(false) + expect(isFiniteNumber(NaN)).toBe(false) + }) + it("should return false for non-numbers", () => { + expect(isFiniteNumber("")).toBe(false) + expect(isFiniteNumber("foo")).toBe(false) + expect(isFiniteNumber({})).toBe(false) + expect(isFiniteNumber([])).toBe(false) + expect(isFiniteNumber(null)).toBe(false) + expect(isFiniteNumber(undefined)).toBe(false) + expect(isFiniteNumber(true)).toBe(false) + expect(isFiniteNumber(false)).toBe(false) + expect(isFiniteNumber(() => {})).toBe(false) + }) + }) + + describe("isValueNonEmpty", () => { + it("should return false for empty values", () => { + expect(isValueNonEmpty("")).toBe(false) + expect(isValueNonEmpty(null)).toBe(false) + expect(isValueNonEmpty(undefined)).toBe(false) + }) + + it("should return true for non-empty values", () => { + expect(isValueNonEmpty("non-empty")).toBe(true) + expect(isValueNonEmpty(0)).toBe(true) + expect(isValueNonEmpty(false)).toBe(true) + }) + }) - describe("between", () => { - it("should return true if the value is between the min and max values", () => { - expect(between(1, 0, 2)).toBe(true) - expect(between(0, 0, 2)).toBe(true) - expect(between(2, 0, 2)).toBe(true) - }) - it("should return false if the value is not between the min and max values", () => { - expect(between(-1, 0, 2)).toBe(false) - expect(between(3, 0, 2)).toBe(false) - expect(between(NaN, 0, 2)).toBe(false) - expect(between(Infinity, 0, 2)).toBe(false) - }) + describe("isNumber", () => { + it("should return true for numbers", () => { + expect(isNumber(0)).toBe(true) + expect(isNumber("0")).toBe(true) + expect(isNumber(1.23)).toBe(true) + expect(isNumber("1.23")).toBe(true) }) - describe("isFiniteNumber", () => { - it("should return true for finite numbers and false for non-finite numbers", () => { - expect(isFiniteNumber(1)).toBe(true) - expect(isFiniteNumber(1.1)).toBe(true) - expect(isFiniteNumber(0)).toBe(true) - expect(isFiniteNumber(Infinity)).toBe(false) - expect(isFiniteNumber(-Infinity)).toBe(false) - expect(isFiniteNumber(NaN)).toBe(false) - }) - it("should return false for non-numbers", () => { - expect(isFiniteNumber("")).toBe(false) - expect(isFiniteNumber("foo")).toBe(false) - expect(isFiniteNumber({})).toBe(false) - expect(isFiniteNumber([])).toBe(false) - expect(isFiniteNumber(null)).toBe(false) - expect(isFiniteNumber(undefined)).toBe(false) - expect(isFiniteNumber(true)).toBe(false) - expect(isFiniteNumber(false)).toBe(false) - expect(isFiniteNumber(() => {})).toBe(false) - }) + it("should return false for non-numbers", () => { + expect(isNumber("")).toBe(false) + expect(isNumber("abc")).toBe(false) + expect(isNumber(null)).toBe(false) + expect(isNumber(undefined)).toBe(false) }) }) }) diff --git a/v3/src/utilities/math-utils.ts b/v3/src/utilities/math-utils.ts index 6b39af660..3cece803a 100644 --- a/v3/src/utilities/math-utils.ts +++ b/v3/src/utilities/math-utils.ts @@ -108,6 +108,12 @@ export function isFiniteNumber(x: any): x is number { return x != null && Number.isFinite(x) } +export const isValueNonEmpty = (value: any) => value !== "" && value != null + +// Similar to isFiniteNumber, but looser. +// It allows for strings that can be converted to numbers and treats Infinity and -Infinity as valid numbers. +export const isNumber = (v: any) => isValueNonEmpty(v) && !isNaN(Number(v)) + export function goodTickValue(iMin: number, iMax: number) { const range = (iMin >= iMax) ? Math.abs(iMin) : iMax - iMin, gap = range / 5