diff --git a/project/src/utils/RandomUtil.ts b/project/src/utils/RandomUtil.ts index 9196e25c..15839d10 100644 --- a/project/src/utils/RandomUtil.ts +++ b/project/src/utils/RandomUtil.ts @@ -195,6 +195,12 @@ export class RandomUtil { @inject("PrimaryLogger") protected logger: ILogger, ) {} + /** + * The IEEE-754 standard for double-precision floating-point numbers limits the number of digits (including both + * integer + fractional parts) to about 15–17 significant digits. 15 is a safe upper bound, so we'll use that. + */ + private static readonly MAX_SIGNIFICANT_DIGITS = 15; + /** * Generates a secure random number between 0 (inclusive) and 1 (exclusive). * @@ -211,6 +217,19 @@ export class RandomUtil { return integer / maxInteger; } + /** + * Determines the number of decimal places in a number. + * + * @param num - The number to analyze. + * @returns The number of decimal places, or 0 if none exist. + * @remarks There is a mathematical way to determine this, but it's not as simple as it seams due to floating point + * precision issues. This method is a simple workaround that converts the number to a string and splits it. + * It's not the most efficient but it *is* the most reliable and easy to understand. Come at me. + */ + private getNumberPrecision(num: number): number { + return num.toString().split(".")[1]?.length || 0; + } + /** * Generates a random integer between the specified minimum and maximum values, inclusive. * @@ -397,6 +416,9 @@ export class RandomUtil { // Detect if either of the parameters is a float, and log a warning if (low % 1 !== 0 || (typeof high !== "undefined" && high % 1 !== 0)) { + this.logger.debug( + "Deprecated: RandomUtil.randInt() called with float input. Use RandomUtil.randNum() instead.", + ); // Round the float values to the nearest integer. Eww! randomLow = Math.floor(low); if (typeof high !== "undefined") { @@ -417,6 +439,62 @@ export class RandomUtil { return crypto.randomInt(randomLow, randomHigh); } + /** + * Generates a random number between two given values with optional precision. + * + * @param value1 - The first value to determine the range. + * @param value2 - The second value to determine the range. If not provided, 0 is used. + * @param precision - The number of decimal places to round the result to. Must be a positive integer between 0 + * and MAX_PRECISION, inclusive. If not provided, precision is determined by the input values. + * @returns A random floating-point number between `value1` and `value2` (inclusive) with the specified precision. + * @throws Will throw an error if `precision` is not a positive integer, if `value1` or `value2` are not finite + * numbers, or if the precision exceeds the maximum allowed for the given values. + */ + public randNum(value1: number, value2 = 0, precision: number | null = null): number { + if (!Number.isFinite(value1) || !Number.isFinite(value2)) { + throw new Error("randNum() parameters 'value1' and 'value2' must be finite numbers"); + } + + // Determine the range by finding the min and max of the provided values + const min = Math.min(value1, value2); + const max = Math.max(value1, value2); + + // Validate and adjust precision + if (precision !== null) { + if (!Number.isInteger(precision) || precision < 0) { + throw new Error(`randNum() parameter 'precision' must be a positive integer`); + } + + // Calculate the number of whole-number digits in the maximum absolute value of the range + const maxAbsoluteValue = Math.max(Math.abs(min), Math.abs(max)); + const wholeNumberDigits = Math.floor(Math.log10(maxAbsoluteValue)) + 1; + + // Determine the maximum allowable precision--The number of decimal places that can be used without losing + // precision due to the number of bits available in a double-precision floating-point number + const maxAllowedPrecision = Math.max(0, RandomUtil.MAX_SIGNIFICANT_DIGITS - wholeNumberDigits); + + // Throw if the requested precision exceeds the maximum + if (precision > maxAllowedPrecision) { + throw new Error( + `randNum() precision of ${precision} exceeds the allowable precision (${maxAllowedPrecision}) for the given values`, + ); + } + } + + // Generate a random number within a specified range + const random = this.getSecureRandomNumber(); + const result = random * (max - min) + min; + + // Determine the maximum precision to use for rounding the result + const maxPrecision = Math.max(this.getNumberPrecision(value1), this.getNumberPrecision(value2)); + const effectivePrecision = precision ?? maxPrecision; + + // Calculate the factor to use for rounding the result to the specified precision + const factor = 10 ** effectivePrecision; + + return Math.round(result * factor) / factor; + } + /** * Draws a specified number of random elements from a given list. * diff --git a/project/tests/utils/RandomUtil.test.ts b/project/tests/utils/RandomUtil.test.ts index a3164f2d..ecb10bf2 100644 --- a/project/tests/utils/RandomUtil.test.ts +++ b/project/tests/utils/RandomUtil.test.ts @@ -14,6 +14,7 @@ describe("RandomUtil", () => { mockLogger = { warning: vi.fn(), info: vi.fn(), + debug: vi.fn(), }; randomUtil = new RandomUtil(mockCloner, mockLogger); }); @@ -22,6 +23,29 @@ describe("RandomUtil", () => { vi.restoreAllMocks(); }); + describe("getNumberPrecision", () => { + it("should return the number of decimal places in a single digit number with up to 15 decimal places (IEEE-754 standard safe-upper bounds)", () => { + const number = 0.123456789012345; + const result = (randomUtil as any).getNumberPrecision(number); + + expect(result).toBe(15); + }); + + it("should return 0 for whole numbers", () => { + const number = 123; + const result = (randomUtil as any).getNumberPrecision(number); + + expect(result).toBe(0); + }); + + it("should return 0 for numbers with zero value decimals", () => { + const number = 123.0; + const result = (randomUtil as any).getNumberPrecision(number); + + expect(result).toBe(0); + }); + }); + describe("getInt", () => { it("should return an integer between min and max inclusive when min < max", () => { const min = 1; @@ -404,6 +428,12 @@ describe("RandomUtil", () => { expect(result).toBeGreaterThanOrEqual(5); expect(result).toBeLessThan(11); }); + + it("should log a debug message with float number parameters", () => { + randomUtil.randInt(5.5, 10.5); + expect(mockLogger.debug).toHaveBeenCalled(); + }); + it("should return an integer between low and high - 1", () => { const low = 5; const high = 10; @@ -433,6 +463,103 @@ describe("RandomUtil", () => { }); }); + describe("randNum", () => { + it("should return the same value when low and high are equal", () => { + const resultWithPrecision = randomUtil.randNum(5.555, 5.555); + expect(resultWithPrecision).toBe(5.555); + + const resultNoPrecision = randomUtil.randNum(7, 7); + expect(resultNoPrecision).toBe(7); + }); + + it("should not throw an error when precision is null", () => { + expect(() => randomUtil.randNum(5, 10, null)).not.toThrow(); + }); + + it("should throw when precision is a float or out of bounds", () => { + const maxPrecision = (RandomUtil as any).MAX_PRECISION; // It's private. + const expectedThrow = `randNum() parameter 'precision' must be a positive integer`; + expect(() => randomUtil.randNum(5, 10, 0.5)).toThrowError(expectedThrow); + expect(() => randomUtil.randNum(5, 10, -1)).toThrowError(expectedThrow); + }); + + it("should use the maximum precision of low and high when precision is null", () => { + const result = randomUtil.randNum(5.123, 10.12345, null); + expect(result.toString().split(".")[1]?.length || 0).toBeLessThanOrEqual(5); // Precision check. + }); + + it("should round to a whole number when precision is 0", () => { + const result = randomUtil.randNum(5.123, 10.12345, 0); + expect(result.toString().split(".")[1]?.length || 0).toBeLessThanOrEqual(0); // Precision check. + }); + + it("should correctly handle cases where high is less than low", () => { + expect(() => randomUtil.randNum(10, 5)).not.toThrow(); + + const result = randomUtil.randNum(10, 5); + expect(result).toBeGreaterThanOrEqual(5); + expect(result).toBeLessThanOrEqual(10); + }); + + it("should throw an error if low or high are not finite numbers", () => { + const expectedThrow = "randNum() parameters 'value1' and 'value2' must be finite numbers"; + expect(() => randomUtil.randNum(Number.POSITIVE_INFINITY, 10)).toThrowError(expectedThrow); + expect(() => randomUtil.randNum(5, Number.NaN)).toThrowError(expectedThrow); + }); + + it("should always return a value within the inclusive range of low and high", () => { + for (let i = 0; i < 100; i++) { + const result = randomUtil.randNum(5, 6); + expect(result).toBeGreaterThanOrEqual(5); + expect(result).toBeLessThanOrEqual(6); + } + }); + + it("should return whole numbers when precision is 0", () => { + const result = randomUtil.randNum(5, 10, 0); + expect(result % 1).toBe(0); // Whole number check. + expect(result).toBeGreaterThanOrEqual(5); + expect(result).toBeLessThanOrEqual(10); + }); + + it("should throw when precision exceeds compatibility with double-precision arithmetic", () => { + const expectedFirstThrow = + "randNum() precision of 16 exceeds the allowable precision (15) for the given values"; + expect(() => randomUtil.randNum(0.1, 0.2, 16)).toThrowError(expectedFirstThrow); + + const expectedSecondThrow = + "randNum() precision of 12 exceeds the allowable precision (11) for the given values"; + expect(() => randomUtil.randNum(1234.1, 1234.2, 12)).toThrowError(expectedSecondThrow); + }); + + it("should default high to low when high is not provided", () => { + const result = randomUtil.randNum(1); + expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBeLessThanOrEqual(1); + }); + + it("should handle negative ranges correctly", () => { + const result = randomUtil.randNum(-10, -5); + expect(result).toBeGreaterThanOrEqual(-10); + expect(result).toBeLessThanOrEqual(-5); + }); + + it("should handle very large numbers correctly", () => { + const result = randomUtil.randNum(1e10, 1e10 + 1); + expect(result).toBeGreaterThanOrEqual(1e10); + expect(result).toBeLessThanOrEqual(1e10 + 1); + }); + + it("should consistently generate valid results over many iterations", () => { + for (let i = 0; i < 5000; i++) { + const result = randomUtil.randNum(1, 2, 3); + expect(result).toBeGreaterThanOrEqual(1); + expect(result).toBeLessThanOrEqual(2); + expect(result.toString().split(".")[1]?.length || 0).toBeLessThanOrEqual(3); // Precision check + } + }); + }); + describe("drawRandomFromList", () => { it("should draw elements with replacement", () => { const list = [1, 2, 3, 4, 5];