0
0
mirror of https://github.com/sp-tarkov/server.git synced 2025-02-13 08:50:43 -05:00
server/project/tests/utils/RandomUtil.test.ts
Refringe d3d7a6ed35
Adds RandomUtil.randNum that can generate random integers or floating-point numbers within a range.
Adds a public `randNum` method to the `RandomUtil` class which can be seen as a "superseeded" version of the `randInt` method. It can handle generating random numbers, including both integers or floating-point numbers with support for providing a precision.

Adds a private `getNumberPrecision` method to the `RandomUtil` class which simply counts the number of fractional digits in a number.

Changes `randInt` to log a debug message saying that the ability to pass it a floating-point number is deprecated.
2024-12-09 21:42:16 -05:00

801 lines
29 KiB
TypeScript

import "reflect-metadata";
import { RandomUtil } from "@spt/utils/RandomUtil";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("RandomUtil", () => {
let randomUtil: RandomUtil;
let mockCloner: any;
let mockLogger: any;
beforeEach(() => {
mockCloner = {
clone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))),
};
mockLogger = {
warning: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
};
randomUtil = new RandomUtil(mockCloner, mockLogger);
});
afterEach(() => {
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;
const max = 5;
const result = randomUtil.getInt(min, max);
expect(result).toBeGreaterThanOrEqual(Math.ceil(min));
expect(result).toBeLessThanOrEqual(Math.floor(max));
expect(Number.isInteger(result)).toBe(true);
});
it("should handle floating-point min and max values", () => {
const min = 1.2;
const max = 5.8;
const result = randomUtil.getInt(min, max);
expect(result).toBeGreaterThanOrEqual(Math.ceil(min)); // 2
expect(result).toBeLessThanOrEqual(Math.floor(max)); // 5
expect(Number.isInteger(result)).toBe(true);
});
it("should return min when min and max are equal", () => {
const min = 3;
const max = 3;
const result = randomUtil.getInt(min, max);
expect(result).toBe(Math.ceil(min));
expect(Number.isInteger(result)).toBe(true);
});
it("should return min when max is less than min", () => {
const min = 5;
const max = 3;
const result = randomUtil.getInt(min, max);
expect(result).toBe(Math.ceil(min));
expect(Number.isInteger(result)).toBe(true);
});
it("should handle negative min and max values", () => {
const min = -5;
const max = -1;
const result = randomUtil.getInt(min, max);
expect(result).toBeGreaterThanOrEqual(Math.ceil(min));
expect(result).toBeLessThanOrEqual(Math.floor(max));
expect(Number.isInteger(result)).toBe(true);
});
});
describe("getIntEx", () => {
it("should return an integer between 1 and max - 2 inclusive when max > 1", () => {
const max = 10;
const result = randomUtil.getIntEx(max);
expect(result).toBeGreaterThanOrEqual(1);
expect(result).toBeLessThanOrEqual(max - 2);
expect(Number.isInteger(result)).toBe(true);
});
it("should return 1 when max is less than or equal to 1", () => {
const maxValues = [1, 0, -5];
for (const max of maxValues) {
const result = randomUtil.getIntEx(max);
expect(result).toBe(1);
}
});
it("should handle edge case when max is 2", () => {
const max = 2;
const result = randomUtil.getIntEx(max);
expect(result).toBe(1);
});
});
describe("getFloat", () => {
it("should return a float between min and max", () => {
const min = 1.5;
const max = 5.5;
const result = randomUtil.getFloat(min, max);
expect(result).toBeGreaterThanOrEqual(min);
expect(result).toBeLessThan(max);
});
it("should handle negative min and max values", () => {
const min = -5.5;
const max = -1.1;
const result = randomUtil.getFloat(min, max);
expect(result).toBeGreaterThanOrEqual(min);
expect(result).toBeLessThan(max);
});
it("should return min when min equals max", () => {
const min = 3.3;
const max = 3.3;
const result = randomUtil.getFloat(min, max);
expect(result).toBe(min);
});
});
describe("getBool", () => {
it("should return a boolean value", () => {
const result = randomUtil.getBool();
expect(typeof result).toBe("boolean");
});
it("should return true when Math.random is less than 0.5", () => {
vi.spyOn(randomUtil as any, "getSecureRandomNumber").mockReturnValue(0.4);
const result = randomUtil.getBool();
expect(result).toBe(true);
});
it("should return false when getSecureRandomNumber returns 0.5", () => {
vi.spyOn(randomUtil as any, "getSecureRandomNumber").mockReturnValue(0.5);
const result = randomUtil.getBool();
expect(result).toBe(false);
});
});
describe("getPercentOfValue", () => {
it("should calculate the correct percentage of a number", () => {
const percent = 25;
const number = 200;
const result = randomUtil.getPercentOfValue(percent, number);
expect(result).toBe(50.0);
});
it("should handle decimal percentages and numbers", () => {
const percent = 12.5;
const number = 80.4;
const result = randomUtil.getPercentOfValue(percent, number);
expect(result).toBeCloseTo(10.05, 2);
});
it("should respect the toFixed parameter", () => {
const percent = 33.3333;
const number = 100;
const result = randomUtil.getPercentOfValue(percent, number, 4);
expect(result).toBe(33.3333);
});
});
describe("reduceValueByPercent", () => {
it("should reduce the value by the given percentage", () => {
const number = 200;
const percentage = 25;
const result = randomUtil.reduceValueByPercent(number, percentage);
expect(result).toBe(150);
});
it("should handle decimal percentages", () => {
const number = 100;
const percentage = 12.5;
const result = randomUtil.reduceValueByPercent(number, percentage);
expect(result).toBe(87.5);
});
it("should return the same number when percentage is 0", () => {
const number = 100;
const percentage = 0;
const result = randomUtil.reduceValueByPercent(number, percentage);
expect(result).toBe(100);
});
});
describe("getChance100", () => {
it("should return true if random number is less than or equal to chancePercent", () => {
vi.spyOn(randomUtil, "getIntEx").mockReturnValue(50);
const chancePercent = 60;
const result = randomUtil.getChance100(chancePercent);
expect(result).toBe(true);
expect(randomUtil.getIntEx).toHaveBeenCalledWith(100);
});
it("should return false if random number is greater than chancePercent", () => {
vi.spyOn(randomUtil, "getIntEx").mockReturnValue(70);
const chancePercent = 60;
const result = randomUtil.getChance100(chancePercent);
expect(result).toBe(false);
});
});
describe("getStringArrayValue", () => {
it("should return a value from the array", () => {
const arr = ["apple", "banana", "cherry"];
const result = randomUtil.getStringArrayValue(arr);
expect(arr).toContain(result);
});
it("should handle single-element arrays", () => {
const arr = ["only"];
const result = randomUtil.getStringArrayValue(arr);
expect(result).toBe("only");
});
it("should return predictable value when getInt is mocked", () => {
vi.spyOn(randomUtil, "getInt").mockReturnValue(1);
const arr = ["first", "second", "third"];
const result = randomUtil.getStringArrayValue(arr);
expect(result).toBe("second");
});
});
describe("getArrayValue", () => {
it("should return a value from the array of numbers", () => {
const arr = [10, 20, 30, 40];
const result = randomUtil.getArrayValue(arr);
expect(arr).toContain(result);
});
it("should return a value from the array of objects", () => {
const arr = [{ id: 1 }, { id: 2 }, { id: 3 }];
const result = randomUtil.getArrayValue(arr);
expect(arr).toContain(result);
});
it("should return predictable value when getInt is mocked", () => {
vi.spyOn(randomUtil, "getInt").mockReturnValue(2);
const arr = ["a", "b", "c", "d"];
const result = randomUtil.getArrayValue(arr);
expect(result).toBe("c");
});
});
describe("getKey", () => {
it("should return a key from the object", () => {
const obj = { a: 1, b: 2, c: 3 };
const result = randomUtil.getKey(obj);
expect(Object.keys(obj)).toContain(result);
});
it("should handle single-key objects", () => {
const obj = { onlyKey: "value" };
const result = randomUtil.getKey(obj);
expect(result).toBe("onlyKey");
});
it("should handle empty objects", () => {
const obj = {};
const result = randomUtil.getKey(obj);
expect(result).toBeUndefined();
});
it("should handle objects with integer keys", () => {
const obj = { 1: "a", 2: "b", 3: "c" };
const result = randomUtil.getKey(obj);
expect(Object.keys(obj)).toContain(result);
});
it("should return predictable key when getArrayValue is mocked", () => {
vi.spyOn(randomUtil, "getArrayValue").mockReturnValue("b");
const obj = { a: 1, b: 2, c: 3 };
const result = randomUtil.getKey(obj);
expect(result).toBe("b");
});
});
describe("getKeyValue", () => {
it("should return a value from the object", () => {
vi.spyOn(randomUtil, "getKey").mockReturnValue("b");
const obj = { a: 1, b: 2, c: 3 };
const result = randomUtil.getKeyValue(obj);
expect(result).toBe(2);
});
it("should handle objects with complex values", () => {
vi.spyOn(randomUtil, "getKey").mockReturnValue("key2");
const obj = { key1: "value1", key2: { nested: true }, key3: [1, 2, 3] };
const result = randomUtil.getKeyValue(obj);
expect(result).toEqual({ nested: true });
});
});
describe("getNormallyDistributedRandomNumber", () => {
it("should return a number close to the mean", () => {
const mean = 50;
const sigma = 5;
const result = randomUtil.getNormallyDistributedRandomNumber(mean, sigma);
expect(result).toBeGreaterThanOrEqual(0);
expect(typeof result).toBe("number");
});
it("should not return negative numbers", () => {
const mean = 5;
const sigma = 10;
const result = randomUtil.getNormallyDistributedRandomNumber(mean, sigma);
expect(result).toBeGreaterThanOrEqual(0);
});
it("should handle high attempt counts", () => {
let callCount = 0;
vi.spyOn(randomUtil as any, "getSecureRandomNumber").mockImplementation(() => {
callCount++;
// Alternate between u and v
if (callCount % 2 === 1) {
// u values
return 0.00001;
}
// v values
return 0.5;
});
const mean = 5;
const sigma = 2;
const result = randomUtil.getNormallyDistributedRandomNumber(mean, sigma);
// Each attempt increases callCount by 2 (once for u, once for v)
const attempts = callCount / 2;
// Ensure that the method attempted multiple times
expect(attempts).toBeGreaterThan(100);
// The result should be from the fallback value
expect(result).toBeGreaterThanOrEqual(0.01);
expect(result).toBeLessThanOrEqual(mean * 2);
});
it("should return a fallback value after many attempts", () => {
let callCount = 0;
vi.spyOn(randomUtil as any, "getSecureRandomNumber").mockImplementation(() => {
// Alternate between u and v
const value = callCount % 2 === 0 ? 0.0000001 : 0.5;
callCount++;
return value;
});
// Mock getFloat to return a predictable value
vi.spyOn(randomUtil, "getFloat").mockReturnValue(7.77);
const mean = 5;
const sigma = 2;
const result = randomUtil.getNormallyDistributedRandomNumber(mean, sigma, 101);
expect(result).toBe(7.77);
});
});
describe("randInt", () => {
it("should return the same value when low and high are equal", () => {
const result = randomUtil.randInt(5, 5);
expect(result).toBe(5);
});
it("should work with float number parameters", () => {
const result = randomUtil.randInt(5.5, 10.5);
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;
const result = randomUtil.randInt(low, high);
expect(result).toBeGreaterThanOrEqual(low);
expect(result).toBeLessThan(high);
expect(Number.isInteger(result)).toBe(true);
});
it("should return an integer between 0 and low - 1 when high is not provided", () => {
const low = 5;
const result = randomUtil.randInt(low);
expect(result).toBeGreaterThanOrEqual(0);
expect(result).toBeLessThan(low);
expect(Number.isInteger(result)).toBe(true);
});
it("should handle negative values", () => {
const low = -10;
const high = -5;
const result = randomUtil.randInt(low, high);
expect(result).toBeGreaterThanOrEqual(low);
expect(result).toBeLessThan(high);
});
});
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];
const count = 3;
const result = randomUtil.drawRandomFromList(list, count, true);
expect(result.length).toBe(count);
for (const item of result) {
expect(list).toContain(item);
}
});
it("should draw elements without replacement", () => {
mockCloner.clone.mockImplementation((obj) => [...obj]);
const list = [1, 2, 3, 4, 5];
const count = 3;
const result = randomUtil.drawRandomFromList(list, count, false);
expect(result.length).toBe(count);
expect(new Set(result).size).toBe(count);
});
it("should clone the original list when drawing without replacement", () => {
const list = [1, 2, 3];
randomUtil.drawRandomFromList(list, 2, false);
expect(mockCloner.clone).toHaveBeenCalledWith(list);
});
it("should handle count greater than list length without replacement", () => {
const list = [1, 2, 3];
const count = 5;
const result = randomUtil.drawRandomFromList(list, count, false);
expect(result.length).toBe(3);
});
it("should adjust count when count exceeds list length without replacement", () => {
const list = [10, 20, 30];
const count = 4; // Count exceeds list length
const result = randomUtil.drawRandomFromList(list, count, false);
// The result should contain all elements from the list without duplicates
expect(result.length).toBe(3); // Should be adjusted to list length
expect(new Set(result).size).toBe(3);
// Ensure that the result contains all elements from the original list
expect(result.sort()).toEqual(list.sort());
});
});
describe("drawRandomFromDict", () => {
it("should draw keys from the dictionary with replacement", () => {
const dict = { a: 1, b: 2, c: 3 };
const count = 2;
const result = randomUtil.drawRandomFromDict(dict, count, true);
expect(result.length).toBe(count);
for (const key of result) {
expect(Object.keys(dict)).toContain(key);
}
});
it("should draw keys without replacement", () => {
const dict = { a: 1, b: 2, c: 3 };
const count = 2;
const result = randomUtil.drawRandomFromDict(dict, count, false);
expect(result.length).toBe(count);
expect(new Set(result).size).toBe(count);
});
it("should handle single-key dictionaries", () => {
const dict = { onlyKey: 1 };
const count = 2;
const result = randomUtil.drawRandomFromDict(dict, count, false);
expect(result.length).toBe(1);
expect(result).toEqual(["onlyKey"]);
});
it("should handle dictionaries with integer keys", () => {
const dict = { 1: "a", 2: "b", 3: "c" };
const count = 2;
const result = randomUtil.drawRandomFromDict(dict, count, false);
expect(result.length).toBe(count);
for (const key of result) {
expect(Object.keys(dict)).toContain(key);
}
});
it("should handle count greater than number of keys without replacement", () => {
const dict = { a: 1, b: 2 };
const count = 3;
const result = randomUtil.drawRandomFromDict(dict, count, false);
expect(result.length).toBe(2);
});
it("should adjust count when count exceeds number of keys without replacement", () => {
const dict = { a: 1, b: 2, c: 3 };
const count = 5; // Count exceeds number of keys
const result = randomUtil.drawRandomFromDict(dict, count, false);
// The result should contain all keys without duplicates
expect(result.length).toBe(3); // Should be adjusted to number of keys
expect(new Set(result).size).toBe(3);
// Ensure that the result contains all keys from the dictionary
const dictKeys = Object.keys(dict);
expect(result.sort()).toEqual(dictKeys.sort());
});
});
describe("getBiasedRandomNumber", () => {
it("should return a number within the range", () => {
const min = 1;
const max = 10;
const shift = 2;
const n = 2;
const result = randomUtil.getBiasedRandomNumber(min, max, shift, n);
expect(result).toBeGreaterThanOrEqual(min);
expect(result).toBeLessThanOrEqual(max);
});
it("should throw error when max < min", () => {
expect(() => randomUtil.getBiasedRandomNumber(10, 5, 0, 2)).toThrowError(
"Bounded random number generation max is smaller than min (5 < 10)",
);
});
it("should throw error when n < 1", () => {
expect(() => randomUtil.getBiasedRandomNumber(1, 10, 0, 0)).toThrowError(
"'n' must be 1 or greater (received 0)",
);
});
it("should log warning when shift is greater than range", () => {
const min = 1;
const max = 5;
const shift = 10;
const n = 2;
randomUtil.getBiasedRandomNumber(min, max, shift, n);
expect(mockLogger.warning).toHaveBeenCalled();
expect(mockLogger.info).toHaveBeenCalledWith(`min -> ${min}; max -> ${max}; shift -> ${shift}`);
});
it("should return predictable result when getSecureRandomNumber is mocked", () => {
vi.spyOn(randomUtil as any, "getSecureRandomNumber").mockReturnValue(0.5);
const min = 1;
const max = 10;
const shift = 0;
const n = 2; // n affects how many times getSecureRandomNumber is summed/averaged
// With getSecureRandomNumber always returning 0.5,
// gaussianRandom(n) = 0.5 no matter what
// boundedGaussian(start, end, n) = round(start + 0.5 * (end - start + 1))
// For shift = 0:
// biasedMin = min = 1
// biasedMax = max = 10
// boundedGaussian(1, 10, 2) = round(1 + 0.5*(10 - 1 + 1)) = round(1 + 0.5*10) = round(1+5) = 6
// The loop ensures num is within [min, max], and since 6 is within [1,10], it returns 6 immediately.
const result = randomUtil.getBiasedRandomNumber(min, max, shift, n);
expect(result).toBe(6);
});
});
describe("shuffle", () => {
it("should shuffle the array", () => {
const array = [1, 2, 3, 4, 5];
const shuffled = randomUtil.shuffle([...array]);
expect(shuffled).toHaveLength(array.length);
expect(shuffled).not.toEqual(array);
expect(shuffled.sort()).toEqual(array.sort());
});
it("should handle empty arrays", () => {
const array: any[] = [];
const shuffled = randomUtil.shuffle(array);
expect(shuffled).toEqual([]);
});
it("should return the same array when array length is 1", () => {
const array = [1];
const shuffled = randomUtil.shuffle(array);
expect(shuffled).toEqual([1]);
});
});
describe("rollForChanceProbability", () => {
it("should return true when rolled chance is less than or equal to probabilityChance", () => {
vi.spyOn(randomUtil as any, "getSecureRandomNumber").mockReturnValue(0.5);
const probabilityChance = 0.6;
const result = randomUtil.rollForChanceProbability(probabilityChance);
expect(result).toBe(true);
});
it("should return false when rolled chance is greater than probabilityChance", () => {
vi.spyOn(randomUtil as any, "getSecureRandomNumber").mockReturnValue(0.7);
const probabilityChance = 0.6;
const result = randomUtil.rollForChanceProbability(probabilityChance);
expect(result).toBe(false);
});
it("should handle probabilityChance of 0", () => {
vi.spyOn(randomUtil as any, "getSecureRandomNumber").mockReturnValue(0.1);
const probabilityChance = 0;
const result = randomUtil.rollForChanceProbability(probabilityChance);
expect(result).toBe(false);
});
it("should handle probabilityChance of 1", () => {
vi.spyOn(randomUtil as any, "getSecureRandomNumber").mockReturnValue(0.99);
const probabilityChance = 1;
const result = randomUtil.rollForChanceProbability(probabilityChance);
expect(result).toBe(true);
});
});
});