feat: add NumberUtils class with safe number manipulation methods and comprehensive tests; enhance StringUtils with UUID and non-empty string validation

This commit is contained in:
Firman Ramdhani
2026-01-29 13:52:54 +07:00
parent a1db9b93e1
commit a2e317ca9e
4 changed files with 462 additions and 0 deletions
@@ -0,0 +1,218 @@
import { describe, it, expect } from 'vitest';
import { NumberUtils } from './number.utils';
describe('NumberUtils', () => {
// ==========================================
// 1. Parsing & Conversion Tests
// ==========================================
describe('toSafeFloat', () => {
it('should return the number itself if input is a valid number', () => {
expect(NumberUtils.toSafeFloat(10.5)).toBe(10.5);
expect(NumberUtils.toSafeFloat(-5)).toBe(-5);
expect(NumberUtils.toSafeFloat(0)).toBe(0);
});
it('should parse string inputs correctly', () => {
expect(NumberUtils.toSafeFloat('123.45')).toBe(123.45);
expect(NumberUtils.toSafeFloat('-10')).toBe(-10);
});
it('should clean dirty strings before parsing', () => {
// Regex implementation: /[^0-9.-]/g
expect(NumberUtils.toSafeFloat('Rp 15000')).toBe(15000);
expect(NumberUtils.toSafeFloat('IDR 10.50')).toBe(10.5);
});
it('should return fallback value for invalid inputs (NaN/Null/Undefined)', () => {
expect(NumberUtils.toSafeFloat(NaN)).toBe(0);
expect(NumberUtils.toSafeFloat(null, 99)).toBe(99);
expect(NumberUtils.toSafeFloat(undefined, -1)).toBe(-1);
expect(NumberUtils.toSafeFloat('abc', 0)).toBe(0);
expect(NumberUtils.toSafeFloat({}, 5)).toBe(5);
});
});
describe('toSafeInteger', () => {
it('should convert float to integer (truncating decimals)', () => {
expect(NumberUtils.toSafeInteger(10.9)).toBe(10);
expect(NumberUtils.toSafeInteger(5.1)).toBe(5);
});
it('should parse string to integer', () => {
expect(NumberUtils.toSafeInteger('20.5')).toBe(20);
expect(NumberUtils.toSafeInteger('100')).toBe(100);
});
it('should return fallback for invalid inputs', () => {
expect(NumberUtils.toSafeInteger('invalid', 0)).toBe(0);
expect(NumberUtils.toSafeInteger(null, 1)).toBe(1);
});
});
// ==========================================
// 2. Arithmetic Safety Tests
// ==========================================
describe('safeDivide', () => {
it('should divide correctly', () => {
expect(NumberUtils.safeDivide(10, 2)).toBe(5);
expect(NumberUtils.safeDivide(5, 2)).toBe(2.5);
});
it('should return fallback when dividing by zero', () => {
expect(NumberUtils.safeDivide(10, 0)).toBe(0); // Default fallback
expect(NumberUtils.safeDivide(10, 0, 999)).toBe(999); // Custom fallback
});
it('should return fallback when inputs are invalid/Infinity', () => {
expect(NumberUtils.safeDivide(Infinity, 10)).toBe(0);
expect(NumberUtils.safeDivide(10, Infinity)).toBe(0);
expect(NumberUtils.safeDivide(NaN, 2)).toBe(0);
});
});
// ==========================================
// 3. Precision & Rounding Tests (Critical for ERP)
// ==========================================
describe('roundToDecimal', () => {
it('should round correctly for standard inputs', () => {
expect(NumberUtils.roundToDecimal(10.556, 2)).toBe(10.56);
expect(NumberUtils.roundToDecimal(10.554, 2)).toBe(10.55);
});
it('should handle floating point errors (0.1 + 0.2)', () => {
// 0.1 + 0.2 = 0.30000000000000004
const floatingPointIssue = 0.1 + 0.2;
expect(NumberUtils.roundToDecimal(floatingPointIssue, 1)).toBe(0.3);
});
it('should handle specific edge cases requiring Epsilon', () => {
// Classic JavaScript case: 1.005.toFixed(2) usually returns "1.00" (wrong)
// Should be 1.01
expect(NumberUtils.roundToDecimal(1.005, 2)).toBe(1.01);
});
it('should handle negative numbers', () => {
expect(NumberUtils.roundToDecimal(-10.556, 2)).toBe(-10.56);
});
});
describe('ceilToDecimal', () => {
it('should ceil correctly with precision', () => {
expect(NumberUtils.ceilToDecimal(10.111, 2)).toBe(10.12);
expect(NumberUtils.ceilToDecimal(10.001, 2)).toBe(10.01);
});
});
describe('floorToDecimal', () => {
it('should floor correctly with precision', () => {
expect(NumberUtils.floorToDecimal(10.999, 2)).toBe(10.99);
});
});
// ==========================================
// 4. Validation & Guards Tests
// ==========================================
describe('isWithinRange', () => {
it('should return true if value is inside range', () => {
expect(NumberUtils.isWithinRange(5, 0, 10)).toBe(true);
});
it('should return true if value is exactly min or max', () => {
expect(NumberUtils.isWithinRange(0, 0, 10)).toBe(true);
expect(NumberUtils.isWithinRange(10, 0, 10)).toBe(true);
});
it('should return false if value is outside range', () => {
expect(NumberUtils.isWithinRange(-1, 0, 10)).toBe(false);
expect(NumberUtils.isWithinRange(11, 0, 10)).toBe(false);
});
});
describe('clampValue', () => {
it('should return value if within range', () => {
expect(NumberUtils.clampValue(50, 0, 100)).toBe(50);
});
it('should return min if value is below min', () => {
expect(NumberUtils.clampValue(-10, 0, 100)).toBe(0);
});
it('should return max if value is above max', () => {
expect(NumberUtils.clampValue(150, 0, 100)).toBe(100);
});
});
describe('isPositiveInteger', () => {
it('should return true for positive integers', () => {
// Kasus valid: ID Auto-Increment, Halaman Pagination, Quantity Barang
expect(NumberUtils.isPositiveInteger(1)).toBe(true);
expect(NumberUtils.isPositiveInteger(999999)).toBe(true);
});
it('should return false for zero', () => {
// 0 is usually not a valid ID in a standard SQL database, and is not a valid quantity for a cart
expect(NumberUtils.isPositiveInteger(0)).toBe(false);
});
it('should return false for negative numbers', () => {
expect(NumberUtils.isPositiveInteger(-1)).toBe(false);
expect(NumberUtils.isPositiveInteger(-99)).toBe(false);
});
it('should return false for floating point numbers', () => {
// Important: Ensure there are no decimal IDs (e.g. 10.5)
expect(NumberUtils.isPositiveInteger(10.5)).toBe(false);
expect(NumberUtils.isPositiveInteger(1.00001)).toBe(false);
});
it('should return false for non-number types', () => {
// Safety check for dirty input (numeric strings, null, etc.)
expect(NumberUtils.isPositiveInteger('123')).toBe(false); // String number rejected (strict check)
expect(NumberUtils.isPositiveInteger('abc')).toBe(false);
expect(NumberUtils.isPositiveInteger(null)).toBe(false);
expect(NumberUtils.isPositiveInteger(undefined)).toBe(false);
expect(NumberUtils.isPositiveInteger(NaN)).toBe(false);
});
});
// ==========================================
// 5. Formatting & Presentation Tests
// ==========================================
describe('padZero', () => {
it('should add leading zeros', () => {
expect(NumberUtils.padZero(5, 3)).toBe('005');
expect(NumberUtils.padZero(50, 4)).toBe('0050');
});
it('should not add zeros if length is already sufficient', () => {
expect(NumberUtils.padZero(123, 3)).toBe('123');
expect(NumberUtils.padZero(12345, 3)).toBe('12345');
});
});
describe('formatWithUnit', () => {
it('should format number with unit', () => {
expect(NumberUtils.formatWithUnit(10, 'kg')).toBe('10 kg');
});
it('should handle decimals correctly', () => {
expect(NumberUtils.formatWithUnit(10.556, 'm', 2)).toBe('10.56 m');
});
it('should handle string inputs safely', () => {
expect(NumberUtils.formatWithUnit('100.5' as unknown as number, 'pcs')).toBe('100.5 pcs');
});
});
describe('toPercentageString', () => {
it('should convert decimal to percentage string', () => {
// 0.125 -> 12.5%
expect(NumberUtils.toPercentageString(0.125, 1)).toBe('12.5%');
});
it('should handle rounding in percentage', () => {
// 0.1256 -> 12.56%
expect(NumberUtils.toPercentageString(0.1256, 2)).toBe('12.56%');
});
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* NumberUtils
* A collection of static functions for safe number manipulation (defensive coding).
* Designed to handle NaN, Infinity, and floating-point arithmetic issues common in JS.
*/
export class NumberUtils {
// ==========================================
// 1. Parsing & Conversion (Safe Casting)
// ==========================================
/**
* Converts unknown input into a safe floating-point number.
* Returns a fallback value if the input is invalid or NaN.
* Use this instead of raw parseFloat().
* * @param input - The value to parse (string, number, or unknown).
* @param fallbackValue - Value to return if parsing fails (default: 0).
*/
static toSafeFloat(input: unknown, fallbackValue: number = 0): number {
if (typeof input === 'number') {
return Number.isFinite(input) ? input : fallbackValue;
}
if (typeof input === 'string') {
// Remove common non-numeric characters except dots and minus signs
// (e.g., "IDR 15,000.00" -> depending on locale settings, this regex is basic)
const cleanStr = input.replace(/[^0-9.-]/g, '');
const parsed = parseFloat(cleanStr);
return Number.isFinite(parsed) ? parsed : fallbackValue;
}
return fallbackValue;
}
/**
* Converts input into a safe integer.
* Useful for item quantities, page numbers, or database IDs.
*/
static toSafeInteger(input: unknown, fallbackValue: number = 0): number {
const floatVal = this.toSafeFloat(input, NaN);
if (Number.isNaN(floatVal)) return fallbackValue;
return Math.trunc(floatVal);
}
// ==========================================
// 2. Arithmetic Safety (Preventing Crash/Infinity)
// ==========================================
/**
* Performs division safely.
* Prevents returning 'Infinity' if the denominator is 0.
* Critical for financial reports (e.g., calculating Profit Margin).
* * @param numerator - The number to be divided.
* @param denominator - The number to divide by.
* @param fallbackValue - Value to return if division is impossible (default: 0).
*/
static safeDivide(numerator: number, denominator: number, fallbackValue: number = 0): number {
if (denominator === 0 || !Number.isFinite(numerator) || !Number.isFinite(denominator)) {
return fallbackValue;
}
return numerator / denominator;
}
// ==========================================
// 3. Precision & Rounding (Floating Point Handler)
// ==========================================
/**
* Rounds a number to a specific decimal precision safely.
* Handles JavaScript floating-point errors (e.g., 0.1 + 0.2 != 0.3).
* * @param amount - The value to round.
* @param decimalPlaces - The number of decimal places (default: 2).
*/
static roundToDecimal(amount: number, decimalPlaces: number = 2): number {
const factor = Math.pow(10, decimalPlaces);
// Uses Epsilon correction to ensure accurate rounding
return Math.round((amount + Number.EPSILON) * factor) / factor;
}
/**
* Rounds a number UP (Ceiling) to a specific decimal precision.
* Useful for logistics calculations (e.g., volumetric weight).
*/
static ceilToDecimal(amount: number, decimalPlaces: number = 0): number {
const factor = Math.pow(10, decimalPlaces);
return Math.ceil((amount + Number.EPSILON) * factor) / factor;
}
/**
* Rounds a number DOWN (Floor) to a specific decimal precision.
*/
static floorToDecimal(amount: number, decimalPlaces: number = 0): number {
const factor = Math.pow(10, decimalPlaces);
return Math.floor((amount + Number.EPSILON) * factor) / factor;
}
// ==========================================
// 4. Validation & Guards
// ==========================================
/**
* Checks if a number falls within a specific range (inclusive).
*/
static isWithinRange(value: number, min: number, max: number): boolean {
return value >= min && value <= max;
}
/**
* Constrains a value between a minimum and maximum limit.
* Useful for UI Components (Sliders, Input Number).
* * @example clampValue(150, 0, 100) -> 100
*/
static clampValue(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
/**
* Checks whether a number is a positive integer.
* Suitable for validating Auto-Increment (INT/BIGINT) IDs,
* Pagination, or Quantity.
*/
static isPositiveInteger(value: unknown): boolean {
return typeof value === 'number' && Number.isInteger(value) && value > 0;
}
// ==========================================
// 5. Formatting & Presentation
// ==========================================
/**
* Pads a number with leading zeros to reach a specific length.
* * @example padZero(5, 3) -> "005"
* Useful for generating Invoice Numbers or SKU codes.
*/
static padZero(value: number, length: number): string {
return value.toString().padStart(length, '0');
}
/**
* Formats a number with a suffix unit safely.
* * @example formatWithUnit(10.5, "Kg") -> "10.5 Kg"
*/
static formatWithUnit(value: number, unit: string, decimalPlaces: number = 2): string {
const safeVal = this.toSafeFloat(value);
const formatted = this.roundToDecimal(safeVal, decimalPlaces);
return `${formatted} ${unit}`;
}
/**
* Formats a number as a percentage string.
* * @example toPercentageString(0.125) -> "12.5%"
*/
static toPercentageString(value: number, decimalPlaces: number = 1): string {
return `${this.roundToDecimal(value * 100, decimalPlaces)}%`;
}
}
@@ -165,4 +165,77 @@ describe('StringUtils', () => {
expect(json).toBe('{"id":1,"name":"Product A"}');
});
});
// ==========================================
// 1. UUID Validation Tests
// ==========================================
describe('isUuid', () => {
// Sample UUID v4 yang valid
const validUuidV4 = 'f47ac10b-58cc-4372-a567-0e02b2c3d479';
// Sample UUID v4 uppercase
const validUuidUpper = 'F47AC10B-58CC-4372-A567-0E02B2C3D479';
it('should return true for a valid UUID v4 string', () => {
expect(StringUtils.isUuid(validUuidV4)).toBe(true);
});
it('should return true for uppercase UUID (case insensitive)', () => {
expect(StringUtils.isUuid(validUuidUpper)).toBe(true);
});
it('should return false for invalid UUID formats', () => {
// One character missing
expect(StringUtils.isUuid('f47ac10b-58cc-4372-a567-0e02b2c3d47')).toBe(false);
// Correct format but non-hex character (z)
expect(StringUtils.isUuid('z47ac10b-58cc-4372-a567-0e02b2c3d479')).toBe(false);
// No hyphens (-)
expect(StringUtils.isUuid('f47ac10b58cc4372a5670e02b2c3d479')).toBe(false);
// Plain random string
expect(StringUtils.isUuid('random-string-not-uuid')).toBe(false);
});
it('should return false for empty strings', () => {
expect(StringUtils.isUuid('')).toBe(false);
});
it('should return false for non-string types', () => {
expect(StringUtils.isUuid(null)).toBe(false);
expect(StringUtils.isUuid(undefined)).toBe(false);
expect(StringUtils.isUuid(12345)).toBe(false);
expect(StringUtils.isUuid({})).toBe(false);
});
});
// ==========================================
// 2. Non-Empty String Validation Tests
// ==========================================
describe('isNonEmptyString', () => {
it('should return true for standard strings', () => {
expect(StringUtils.isNonEmptyString('BRG-001')).toBe(true);
expect(StringUtils.isNonEmptyString('Hello World')).toBe(true);
});
it('should return true for strings with surrounding whitespace', () => {
// .trim() will remove spaces, but the remainder will still be > 0
expect(StringUtils.isNonEmptyString(' user123 ')).toBe(true);
});
it('should return false for empty strings', () => {
expect(StringUtils.isNonEmptyString('')).toBe(false);
});
it('should return false for strings containing only whitespace', () => {
// .trim() will make this string become "" (length 0)
expect(StringUtils.isNonEmptyString(' ')).toBe(false);
expect(StringUtils.isNonEmptyString('\t\n')).toBe(false);
});
it('should return false for non-string types', () => {
expect(StringUtils.isNonEmptyString(null)).toBe(false);
expect(StringUtils.isNonEmptyString(undefined)).toBe(false);
expect(StringUtils.isNonEmptyString(0)).toBe(false); // Angka 0 bukan string
expect(StringUtils.isNonEmptyString(true)).toBe(false);
expect(StringUtils.isNonEmptyString(['a'])).toBe(false);
});
});
});
+18
View File
@@ -57,6 +57,9 @@ interface IStringUtils {
export class StringUtils implements IStringUtils {
private readonly _value: string;
// Standard regex for UUIDs (v4, v5, etc.)
private static readonly UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
/* ----------------------------------------------------------------
* Constructor & Static Initializers
* ---------------------------------------------------------------- */
@@ -272,4 +275,19 @@ export class StringUtils implements IStringUtils {
toJSON(): string {
return this._value;
}
/**
* Validate the UUID format.
*/
static isUuid(value: unknown): boolean {
return typeof value === 'string' && this.UUID_REGEX.test(value);
}
/**
* Validation for IDs that are non-empty strings
* (Example: Item Code "BRG-001", Username, etc.)
*/
static isNonEmptyString(value: unknown): boolean {
return typeof value === 'string' && value.trim().length > 0;
}
}