From 8e896461c2f1bda64e5a9a1781acab9b11f879ff Mon Sep 17 00:00:00 2001 From: bcolloran Date: Fri, 8 Dec 2023 13:54:14 -0800 Subject: [PATCH] Make `formatMsInterval` tighter and add tests (#3669) * make formatMsInterval tighter and add tests * cleanup --- .../strategies/intervals.spec.ts | 150 ++++++++++++++++++ .../number-formatting/strategies/intervals.ts | 76 +++++---- 2 files changed, 187 insertions(+), 39 deletions(-) create mode 100644 web-common/src/lib/number-formatting/strategies/intervals.spec.ts diff --git a/web-common/src/lib/number-formatting/strategies/intervals.spec.ts b/web-common/src/lib/number-formatting/strategies/intervals.spec.ts new file mode 100644 index 00000000000..cc3f9fbea3c --- /dev/null +++ b/web-common/src/lib/number-formatting/strategies/intervals.spec.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from "vitest"; +import { formatMsInterval } from "./intervals"; + +const nonNumericTestCases = [ + null, + undefined, + "foo", + false, + [1, 2, true], + { foo: 6, bar: 7 }, + new Date("1999-9-9"), + BigInt(1e300), + new Map(), + new Set(), + new WeakMap(), + new WeakSet(), + Symbol("foo"), + () => "blah", +]; +describe("formatMsInterval - non numeric inputs", () => { + nonNumericTestCases.forEach((input) => { + let inputString; + try { + inputString = JSON.stringify(input); + } catch (error) { + //@ts-ignore + inputString = input.toString(); + } + + it(`returns the empty string for non numeric input: ${inputString}`, () => { + expect(formatMsInterval(input as unknown as number)).toEqual(""); + }); + }); +}); + +const MS = 1; +const SEC = 1000 * MS; +const MIN = 60 * SEC; +const HOUR = 60 * MIN; +const DAY = 24 * HOUR; +const MONTH = 30 * DAY; //eslint-disable-line +const YEAR = 365 * DAY; //eslint-disable-line + +const time_formula_normal_cases = [ + ["1.797", "1.8 ms"], + ["123.7989", "0.12 s"], + ["793.987", "0.79 s"], + ["100.9797", "0.1 s"], + ["1 * SEC", "1 s"], + ["1.4709879 * SEC", "1.5 s"], + ["9.49797 * SEC", "9.5 s"], + ["10 * SEC", "10 s"], + ["59 * SEC", "59 s"], + ["1 * MIN", "60 s"], + ["99.9 * SEC", "1.7 m"], + ["100 * SEC", "1.7 m"], + ["59.23451 * MIN", "59 m"], + ["89.411 * MIN", "89 m"], + ["89.94353 * MIN", "90 m"], + ["99 * MIN", "1.7 h"], + ["99.9 * MIN", "1.7 h"], + ["100 * MIN", "1.7 h"], + ["71.936 * HOUR", "72 h"], + ["72 * HOUR", "3 d"], + ["99 * HOUR", "4.1 d"], + ["89.9 * DAY", "90 d"], + ["90 * DAY", "3 mon"], + ["99 * DAY", "3.3 mon"], + ["7.87978 * MONTH", "7.9 mon"], + ["17.923 * MONTH", "18 mon"], + ["18 * MONTH", "1.5 y"], + ["18.0234234 * MONTH", "1.5 y"], + ["36 * MONTH", "3 y"], + ["3247 * DAY", "8.9 y"], + ["43.34523 * YEAR", "43 y"], + ["99 * YEAR", "99 y"], + ["99 * YEAR + 6 * SEC", "99 y"], + ["99 * YEAR + 6.0004 * SEC", "99 y"], + ["99 * YEAR + 6.99999 * SEC", "99 y"], + ["99.9 * YEAR", "100 y"], +]; + +describe("formatMsInterval - normal cases", () => { + time_formula_normal_cases.forEach(([input, output]) => { + const ms = eval(input); + it(`return "${output}" for input: ${ms.toString()}ms (${input})`, () => { + expect(formatMsInterval(ms)).toEqual(output); + }); + }); +}); + +describe("formatMsInterval - normal cases, negative", () => { + time_formula_normal_cases.forEach(([input, output]) => { + const ms = -eval(input); + it(`return "${output}" for input: ${ms.toString()}ms (${input})`, () => { + expect(formatMsInterval(ms)).toEqual("-" + output); + }); + }); +}); + +const time_formula_special_cases = [ + ["0", "0 s"], + ["0.0011797", "~0 s"], + ["0.01231", "~0 s"], + ["100.234 * YEAR", ">100 y"], + ["123797.239797 * YEAR", ">100 y"], + ["123797.239797 * YEAR", ">100 y"], + ["123797.239797 * YEAR", ">100 y"], + + // infinitesimals + [0.9, "~0 s"], + [0.095, "~0 s"], + [0.0095, "~0 s"], + [0.001, "~0 s"], + [0.00095, "~0 s"], + [0.000999999, "~0 s"], + [0.00012335234, "~0 s"], + [0.000_000_999999, "~0 s"], + [0.000_000_02341253, "~0 s"], + [0.000_000_000_999999, "~0 s"], + + // negative infinitesimals + [-0.9, "~0 s"], + [-0.095, "~0 s"], + [-0.0095, "~0 s"], + [-0.001, "~0 s"], + [-0.00095, "~0 s"], + [-0.000999999, "~0 s"], + [-0.00012335234, "~0 s"], + [-0.000_000_999999, "~0 s"], + [-0.000_000_02341253, "~0 s"], + [-0.000_000_000_999999, "~0 s"], + + // huge numbers + [1e19, ">100 y"], + [3.2e12, ">100 y"], + + // hugely negative numbers + [-1e19, "< -100 y"], + [-3.2e12, "< -100 y"], +]; + +describe("formatMsInterval - special cases", () => { + time_formula_special_cases.forEach(([input, output]) => { + const ms = eval(input.toString()); + it(`return "${output}" for input: ${ms.toString()}ms (${input})`, () => { + expect(formatMsInterval(ms)).toEqual(output); + }); + }); +}); diff --git a/web-common/src/lib/number-formatting/strategies/intervals.ts b/web-common/src/lib/number-formatting/strategies/intervals.ts index a7a788a65e1..57d805e9e4d 100644 --- a/web-common/src/lib/number-formatting/strategies/intervals.ts +++ b/web-common/src/lib/number-formatting/strategies/intervals.ts @@ -24,19 +24,6 @@ const timeUnits = { y: "y", }; -const ms_breakpoints = [ - { ms: 0 }, - { ms: 1 }, - { ms: 100, divisor: 1, unit: timeUnits.ms }, - { ms: 90 * MS_PER_SEC, divisor: MS_PER_SEC, unit: timeUnits.s }, - { ms: 90 * MS_PER_MIN, divisor: MS_PER_MIN, unit: timeUnits.m }, - { ms: 72 * MS_PER_HOUR, divisor: MS_PER_HOUR, unit: timeUnits.h }, - { ms: 90 * MS_PER_DAY, divisor: MS_PER_DAY, unit: timeUnits.d }, - { ms: 18 * MS_PER_MONTH, divisor: MS_PER_MONTH, unit: timeUnits.mon }, - { ms: 100 * MS_PER_YEAR, divisor: MS_PER_YEAR, unit: timeUnits.y }, - { ms: Infinity, unit: "TOO_LARGE" }, -]; - // TODO: Rewrite this to use the sample and provided options export class IntervalFormatter implements Formatter { options: FormatterOptionsCommon & FormatterRangeSpecsStrategy; @@ -74,37 +61,48 @@ export function formatMsInterval(ms: number): string { // ); return ""; } - let negative = false; - if (ms < 0) { - ms = -ms; - negative = true; - } - - if (ms === 0) { - return `0 ${timeUnits.s}`; - } else if (ms < 1) { - return `~0 ${timeUnits.s}`; - } else if (ms >= 100 * MS_PER_YEAR) { - return negative ? `< -100 ${timeUnits.y}` : `>100 ${timeUnits.y}`; - } - - const i = ms_breakpoints.findIndex((b) => ms < b.ms); - - const breakpoint = ms_breakpoints[i]; - if (breakpoint.unit === "TOO_LARGE") { - return `>100 ${timeUnits.y}`; - } - - const unit = breakpoint.unit; - const value = ms / breakpoint.divisor; - const fmt = Intl.NumberFormat("en-US", { + const format = Intl.NumberFormat("en-US", { maximumFractionDigits: 1, minimumFractionDigits: 0, maximumSignificantDigits: 2, - }).format(value); + }).format; + + let neg: "" | "-" = ""; + if (ms < 0) { + ms = -ms; + neg = "-"; + } - return `${negative ? "-" : ""}${fmt} ${unit}`; + switch (true) { + case ms < 0: + // THIS SHOULD NEVER HAPPEN, any negative values should + // have been made positive above. + console.warn( + `formatMsInterval: negative value ${ms} was not converted to positive.` + ); + return "0 ms"; + case ms === 0: + return `0 ${timeUnits.s}`; + case ms < 1: + return `~0 ${timeUnits.s}`; + case ms < 100: + return `${neg}${format(ms)} ${timeUnits.ms}`; + case ms < 90 * MS_PER_SEC: + return `${neg}${format(ms / MS_PER_SEC)} ${timeUnits.s}`; + case ms < 90 * MS_PER_MIN: + return `${neg}${format(ms / MS_PER_MIN)} ${timeUnits.m}`; + case ms < 72 * MS_PER_HOUR: + return `${neg}${format(ms / MS_PER_HOUR)} ${timeUnits.h}`; + case ms < 90 * MS_PER_DAY: + return `${neg}${format(ms / MS_PER_DAY)} ${timeUnits.d}`; + case ms < 18 * MS_PER_MONTH: + return `${neg}${format(ms / MS_PER_MONTH)} ${timeUnits.mon}`; + case ms < 100 * MS_PER_YEAR: + return `${neg}${format(ms / MS_PER_YEAR)} ${timeUnits.y}`; + default: + return neg === "-" ? `< -100 ${timeUnits.y}` : `>100 ${timeUnits.y}`; + } } /**