feat: TestRNG 클래스 추가 및 관련 테스트 케이스 작성

This commit is contained in:
2025-12-28 11:02:45 +00:00
parent dfad37536b
commit 50ef39be4e
3 changed files with 358 additions and 0 deletions
+1
View File
@@ -8,3 +8,4 @@ export * from './util/convertBytesLikeToUint8Array.js';
export * from './util/LiteHashDRBG.js';
export * from './util/RNG.js';
export * from './util/RandUtil.js';
export * from './util/TestRNG.js';
+284
View File
@@ -0,0 +1,284 @@
import type { RNG } from './RNG.js';
const maxSafeInt = Number.MAX_SAFE_INTEGER;
const clamp01 = (value: number): number => {
if (value < 0) {
return 0;
}
if (value > 1) {
return 1;
}
return value;
};
// 테스트에서 0/1만 고정으로 뽑기 위한 RNG
export class ConstantRNG implements RNG {
private readonly bit: 0 | 1;
public constructor(bit: 0 | 1) {
this.bit = bit;
}
public getMaxInt(): number {
return maxSafeInt;
}
public nextBytes(bytes: number): Uint8Array<ArrayBuffer> {
if (bytes <= 0) {
throw new Error('bytes must be positive');
}
const result = new Uint8Array(bytes);
result.fill(this.bit === 0 ? 0x00 : 0xff);
return result;
}
public nextBits(bits: number): Uint8Array<ArrayBuffer> {
if (bits <= 0) {
throw new Error('bits must be positive');
}
const bytes = (bits + 7) >> 3;
const headBits = bits & 0x7;
const result = this.nextBytes(bytes);
if (headBits === 0) {
return result;
}
result[bytes - 1]! &= 0xff >> (8 - headBits);
return result;
}
public nextInt(max?: number): number {
if (max === undefined || max === maxSafeInt) {
return this.bit === 0 ? 0 : maxSafeInt;
}
if (max > maxSafeInt) {
throw new Error('Over max int');
}
if (max === 0) {
return 0;
}
if (max < 0) {
return -this.nextInt(-max);
}
return this.bit === 0 ? 0 : max;
}
public nextFloat1(): number {
return this.bit;
}
}
// 중간값 고정 + bool은 0/1 교대로 뽑는 RNG
export class MidpointRNG implements RNG {
private bitState: 0 | 1;
public constructor(startBit: 0 | 1 = 0) {
this.bitState = startBit;
}
public getMaxInt(): number {
return maxSafeInt;
}
private nextBitRaw(): 0 | 1 {
const value = this.bitState;
this.bitState = value === 0 ? 1 : 0;
return value;
}
public nextBytes(bytes: number): Uint8Array<ArrayBuffer> {
if (bytes <= 0) {
throw new Error('bytes must be positive');
}
return this.nextBits(bytes * 8);
}
public nextBits(bits: number): Uint8Array<ArrayBuffer> {
if (bits <= 0) {
throw new Error('bits must be positive');
}
const bytes = (bits + 7) >> 3;
const result = new Uint8Array(bytes);
for (let bitIdx = 0; bitIdx < bits; bitIdx += 1) {
if (this.nextBitRaw() === 0) {
continue;
}
const byteIdx = bitIdx >> 3;
const offset = bitIdx & 0x7;
result[byteIdx]! |= 1 << offset;
}
return result;
}
public nextInt(max?: number): number {
if (max === undefined || max === maxSafeInt) {
return Math.floor(maxSafeInt / 2);
}
if (max > maxSafeInt) {
throw new Error('Over max int');
}
if (max === 0) {
return 0;
}
if (max < 0) {
return -this.nextInt(-max);
}
return Math.floor(max / 2);
}
public nextFloat1(): number {
return 0.5;
}
}
// 사인파 기반으로 주기/진폭을 조절하는 RNG
export class SineRNG implements RNG {
private step = 0;
private readonly period: number;
private readonly amplitude: number;
private readonly phase: number;
public constructor(period = 32, amplitude = 0.5, phase = 0) {
if (period <= 0) {
throw new Error('period must be positive');
}
this.period = period;
this.amplitude = amplitude;
this.phase = phase;
}
public getMaxInt(): number {
return maxSafeInt;
}
private nextWaveFloat(): number {
const radians = this.phase + (this.step * 2 * Math.PI) / this.period;
const value = 0.5 + this.amplitude * Math.sin(radians);
this.step += 1;
return clamp01(value);
}
public nextBytes(bytes: number): Uint8Array<ArrayBuffer> {
if (bytes <= 0) {
throw new Error('bytes must be positive');
}
const result = new Uint8Array(bytes);
for (let idx = 0; idx < bytes; idx += 1) {
const value = Math.floor(this.nextWaveFloat() * 256);
result[idx] = value >= 256 ? 255 : value;
}
return result;
}
public nextBits(bits: number): Uint8Array<ArrayBuffer> {
if (bits <= 0) {
throw new Error('bits must be positive');
}
const bytes = (bits + 7) >> 3;
const headBits = bits & 0x7;
const result = this.nextBytes(bytes);
if (headBits === 0) {
return result;
}
result[bytes - 1]! &= 0xff >> (8 - headBits);
return result;
}
public nextInt(max?: number): number {
if (max === undefined || max === maxSafeInt) {
const value = Math.floor(this.nextWaveFloat() * (maxSafeInt + 1));
return value > maxSafeInt ? maxSafeInt : value;
}
if (max > maxSafeInt) {
throw new Error('Over max int');
}
if (max === 0) {
return 0;
}
if (max < 0) {
return -this.nextInt(-max);
}
const value = Math.floor(this.nextWaveFloat() * (max + 1));
return value > max ? max : value;
}
public nextFloat1(): number {
return this.nextWaveFloat();
}
}
// 지정한 수열을 반복 재생하는 테스트용 RNG
export class SequenceRNG implements RNG {
private readonly sequence: number[];
private idx = 0;
public constructor(sequence: number[]) {
if (sequence.length === 0) {
throw new Error('sequence must not be empty');
}
this.sequence = sequence.map(clamp01);
}
public getMaxInt(): number {
return maxSafeInt;
}
private nextValue(): number {
const value = this.sequence[this.idx]!;
this.idx = (this.idx + 1) % this.sequence.length;
return value;
}
public nextBytes(bytes: number): Uint8Array<ArrayBuffer> {
if (bytes <= 0) {
throw new Error('bytes must be positive');
}
const result = new Uint8Array(bytes);
for (let idx = 0; idx < bytes; idx += 1) {
const value = Math.floor(this.nextValue() * 256);
result[idx] = value >= 256 ? 255 : value;
}
return result;
}
public nextBits(bits: number): Uint8Array<ArrayBuffer> {
if (bits <= 0) {
throw new Error('bits must be positive');
}
const bytes = (bits + 7) >> 3;
const headBits = bits & 0x7;
const result = this.nextBytes(bytes);
if (headBits === 0) {
return result;
}
result[bytes - 1]! &= 0xff >> (8 - headBits);
return result;
}
public nextInt(max?: number): number {
if (max === undefined || max === maxSafeInt) {
const value = Math.floor(this.nextValue() * (maxSafeInt + 1));
return value > maxSafeInt ? maxSafeInt : value;
}
if (max > maxSafeInt) {
throw new Error('Over max int');
}
if (max === 0) {
return 0;
}
if (max < 0) {
return -this.nextInt(-max);
}
const value = Math.floor(this.nextValue() * (max + 1));
return value > max ? max : value;
}
public nextFloat1(): number {
return this.nextValue();
}
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { ConstantRNG, MidpointRNG, SequenceRNG, SineRNG } from '../src/util/TestRNG.js';
const toArray = (bytes: Uint8Array): number[] => Array.from(bytes);
describe('TestRNG:Constant', () => {
it('returns fixed 0', () => {
const rng = new ConstantRNG(0);
expect(rng.nextFloat1()).toBe(0);
expect(rng.nextInt(10)).toBe(0);
expect(toArray(rng.nextBytes(3))).toEqual([0, 0, 0]);
expect(toArray(rng.nextBits(3))).toEqual([0]);
});
it('returns fixed 1', () => {
const rng = new ConstantRNG(1);
expect(rng.nextFloat1()).toBe(1);
expect(rng.nextInt(10)).toBe(10);
expect(toArray(rng.nextBytes(2))).toEqual([255, 255]);
expect(toArray(rng.nextBits(3))).toEqual([7]);
});
});
describe('TestRNG:Midpoint', () => {
it('returns midpoint for int/float', () => {
const rng = new MidpointRNG();
expect(rng.nextFloat1()).toBe(0.5);
expect(rng.nextInt(9)).toBe(4);
expect(rng.nextInt(10)).toBe(5);
});
it('alternates bits', () => {
const rng = new MidpointRNG();
expect(toArray(rng.nextBits(4))).toEqual([10]);
expect(toArray(rng.nextBits(4))).toEqual([10]);
});
});
describe('TestRNG:Sine', () => {
it('follows sine wave with period/amplitude', () => {
const rng = new SineRNG(4, 0.5, 0);
expect(rng.nextFloat1()).toBeCloseTo(0.5, 8);
expect(rng.nextFloat1()).toBeCloseTo(1, 8);
expect(rng.nextFloat1()).toBeCloseTo(0.5, 8);
expect(rng.nextFloat1()).toBeCloseTo(0, 8);
});
it('maps float to int range', () => {
const rng = new SineRNG(4, 0.5, 0);
expect(rng.nextInt(9)).toBe(5);
expect(rng.nextInt(9)).toBe(9);
});
});
describe('TestRNG:Sequence', () => {
it('cycles fixed sequence', () => {
const rng = new SequenceRNG([0, 0.25, 0.5, 0.75, 1]);
expect(rng.nextFloat1()).toBe(0);
expect(rng.nextFloat1()).toBe(0.25);
expect(rng.nextFloat1()).toBe(0.5);
expect(rng.nextFloat1()).toBe(0.75);
expect(rng.nextFloat1()).toBe(1);
expect(rng.nextFloat1()).toBe(0);
});
it('converts sequence to bytes and ints', () => {
const rng = new SequenceRNG([0, 0.5, 1]);
expect(toArray(rng.nextBytes(3))).toEqual([0, 128, 255]);
expect(rng.nextInt(8)).toBe(0);
expect(rng.nextInt(8)).toBe(4);
expect(rng.nextInt(8)).toBe(8);
});
});