From 5a2fcf7a9f7a58944f4161aa01901ab564d76fc2 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 6 Jul 2025 22:00:54 +0300 Subject: [PATCH] Add tests and documentation to number utils Also improved safeNumberParse robustness --- app/utils/number.ts | 57 +++++++++++++++++++- app/utils/numbers.test.ts | 109 +++++++++++++++++++++++++++++++++++++- 2 files changed, 163 insertions(+), 3 deletions(-) diff --git a/app/utils/number.ts b/app/utils/number.ts index 7999fa019..d4f8ba502 100644 --- a/app/utils/number.ts +++ b/app/utils/number.ts @@ -1,9 +1,30 @@ import * as R from "remeda"; +/** + * Rounds a number to a specified number of decimal places. + * + * @example + * ```typescript + * roundToNDecimalPlaces(3.14159); // returns 3.14 + * roundToNDecimalPlaces(3.14159, 3); // returns 3.142 + * roundToNDecimalPlaces(2.5, 0); // returns 3 + * ``` + */ export function roundToNDecimalPlaces(num: number, n = 2) { return Number((Math.round(num * 10 ** n) / 10 ** n).toFixed(n)); } +/** + * Truncates a number to a specified number of decimal places without rounding. + * + * @example + * ```typescript + * cutToNDecimalPlaces(3.9999, 2); // returns 3.99 + * cutToNDecimalPlaces(3.12, 1); // returns 3.1 + * cutToNDecimalPlaces(100, 2); // returns 100 + * cutToNDecimalPlaces(3.0001, 2); // returns 3 + * ``` + */ export function cutToNDecimalPlaces(num: number, n = 2) { const multiplier = 10 ** n; const truncatedNum = Math.trunc(num * multiplier) / multiplier; @@ -11,13 +32,47 @@ export function cutToNDecimalPlaces(num: number, n = 2) { return Number(n > 0 ? result.replace(/\.?0+$/, "") : result); } +/** + * Calculates the average (arithmetic mean) of an array of numbers. + * Returns 0 if the array is empty. + * + * @example + * ```typescript + * averageArray([2, 4, 6, 8]); // returns 5 + * averageArray([-2, -4, -6, -8]); // returns -5 + * averageArray([10, -10, 20, -20]); // returns 0 + * averageArray([42]); // returns 42 + * averageArray([]); // returns 0 + * ``` + */ export function averageArray(arr: number[]) { + if (arr.length === 0) return 0; + return R.sum(arr) / arr.length; } +/** + * Safely parses a string into a number, returning `null` if the input is `null`, + * empty, or not a valid number. + * + * Trims whitespace from the input before parsing. If the trimmed string is empty + * or cannot be converted to a valid number, returns `null`. + * + * @example + * ```typescript + * safeNumberParse("42"); // returns 42 + * safeNumberParse(" 3.14 "); // returns 3.14 + * safeNumberParse(""); // returns null + * safeNumberParse("abc"); // returns null + * safeNumberParse(null); // returns null + * ``` + */ export function safeNumberParse(value: string | null) { if (value === null) return null; - const result = Number(value); + const trimmed = value.trim(); + if (trimmed === "") return null; + + const result = Number(trimmed); return Number.isNaN(result) ? null : result; } diff --git a/app/utils/numbers.test.ts b/app/utils/numbers.test.ts index db19f37d3..25323d270 100644 --- a/app/utils/numbers.test.ts +++ b/app/utils/numbers.test.ts @@ -1,5 +1,45 @@ -import { describe, expect, test } from "vitest"; -import { cutToNDecimalPlaces } from "./number"; +import { describe, expect, it, test } from "vitest"; +import { + averageArray, + cutToNDecimalPlaces, + roundToNDecimalPlaces, + safeNumberParse, +} from "./number"; + +describe("roundToNDecimalPlaces()", () => { + it("rounds to 2 decimal places by default", () => { + expect(roundToNDecimalPlaces(1.234)).toBe(1.23); + expect(roundToNDecimalPlaces(1.235)).toBe(1.24); + expect(roundToNDecimalPlaces(1.2)).toBe(1.2); + expect(roundToNDecimalPlaces(1)).toBe(1); + }); + + it("rounds to 0 decimal places", () => { + expect(roundToNDecimalPlaces(1.6, 0)).toBe(2); + expect(roundToNDecimalPlaces(1.4, 0)).toBe(1); + expect(roundToNDecimalPlaces(2.5, 0)).toBe(3); + }); + + it("rounds to 3 decimal places", () => { + expect(roundToNDecimalPlaces(1.23456, 3)).toBe(1.235); + expect(roundToNDecimalPlaces(1.23444, 3)).toBe(1.234); + }); + + it("handles negative numbers", () => { + expect(roundToNDecimalPlaces(-1.2345, 2)).toBe(-1.23); + expect(roundToNDecimalPlaces(-1.2355, 2)).toBe(-1.24); + }); + + it("handles zero", () => { + expect(roundToNDecimalPlaces(0, 2)).toBe(0); + expect(roundToNDecimalPlaces(0, 0)).toBe(0); + }); + + it("handles large numbers", () => { + expect(roundToNDecimalPlaces(123456.789, 1)).toBe(123456.8); + expect(roundToNDecimalPlaces(123456.789, 0)).toBe(123457); + }); +}); describe("cutToNDecimalPlaces()", () => { test("cutOff truncates decimal places correctly", () => { @@ -22,3 +62,68 @@ describe("cutToNDecimalPlaces()", () => { expect(result).toBe(3); }); }); + +describe("averageArray()", () => { + it("returns the average of positive numbers", () => { + const result = averageArray([2, 4, 6, 8]); + expect(result).toBe(5); + }); + + it("returns the average of negative numbers", () => { + const result = averageArray([-2, -4, -6, -8]); + expect(result).toBe(-5); + }); + + it("returns the average of mixed positive and negative numbers", () => { + const result = averageArray([10, -10, 20, -20]); + expect(result).toBe(0); + }); + + it("returns the value itself for a single-element array", () => { + const result = averageArray([42]); + expect(result).toBe(42); + }); + + it("returns 0 for an empty array", () => { + const result = averageArray([]); + expect(result).toBe(0); + }); +}); + +describe("safeNumberParse()", () => { + it("returns null for null input", () => { + expect(safeNumberParse(null)).toBeNull(); + }); + + it("parses valid integer string", () => { + expect(safeNumberParse("42")).toBe(42); + }); + + it("parses valid float string", () => { + expect(safeNumberParse("3.14")).toBe(3.14); + }); + + it("returns null for non-numeric string", () => { + expect(safeNumberParse("abc")).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(safeNumberParse("")).toBeNull(); + }); + + it("parses string with leading/trailing spaces", () => { + expect(safeNumberParse(" 7 ")).toBe(7); + }); + + it("parses negative numbers", () => { + expect(safeNumberParse("-123")).toBe(-123); + }); + + it("parses zero", () => { + expect(safeNumberParse("0")).toBe(0); + }); + + it("returns null for string with only spaces", () => { + expect(safeNumberParse(" ")).toBeNull(); + }); +});