diff --git a/packages/utils/src/number/number.utils.test.ts b/packages/utils/src/number/number.utils.test.ts new file mode 100644 index 0000000..9472fcb --- /dev/null +++ b/packages/utils/src/number/number.utils.test.ts @@ -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%'); + }); + }); +}); diff --git a/packages/utils/src/number/number.utils.ts b/packages/utils/src/number/number.utils.ts new file mode 100644 index 0000000..f31629c --- /dev/null +++ b/packages/utils/src/number/number.utils.ts @@ -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)}%`; + } +} diff --git a/packages/utils/src/string/string.utils.test.ts b/packages/utils/src/string/string.utils.test.ts index 8eb5281..ead94a1 100644 --- a/packages/utils/src/string/string.utils.test.ts +++ b/packages/utils/src/string/string.utils.test.ts @@ -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); + }); + }); }); diff --git a/packages/utils/src/string/string.utils.ts b/packages/utils/src/string/string.utils.ts index 0e50a1b..277861d 100644 --- a/packages/utils/src/string/string.utils.ts +++ b/packages/utils/src/string/string.utils.ts @@ -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; + } }