Skip to content

Commit

Permalink
Implement ULID arbitrary
Browse files Browse the repository at this point in the history
  • Loading branch information
vecerek committed Jul 2, 2023
1 parent 51c63a5 commit 72e62cc
Show file tree
Hide file tree
Showing 4 changed files with 266 additions and 0 deletions.
@@ -0,0 +1,125 @@
/** @internal */
const encodeSymbolLookupTable: Record<number, string> = {
10: 'A',
11: 'B',
12: 'C',
13: 'D',
14: 'E',
15: 'F',
16: 'G',
17: 'H',
18: 'J',
19: 'K',
20: 'M',
21: 'N',
22: 'P',
23: 'Q',
24: 'R',
25: 'S',
26: 'T',
27: 'V',
28: 'W',
29: 'X',
30: 'Y',
31: 'Z',
};

/** @internal */
const decodeSymbolLookupTable: Record<string, number> = {
'0': 0,
O: 0,
'1': 1,
'2': 2,
'3': 3,
'4': 4,
'5': 5,
'6': 6,
'7': 7,
'8': 8,
'9': 9,
A: 10,
B: 11,
C: 12,
D: 13,
E: 14,
F: 15,
G: 16,
H: 17,
J: 18,
K: 19,
M: 20,
N: 21,
P: 22,
Q: 23,
R: 24,
S: 25,
T: 26,
V: 27,
W: 28,
X: 29,
Y: 30,
Z: 31,
};

/** @internal */
function getBaseLog(x: number, y: number) {
return Math.log(y) / Math.log(x);
}

/** @internal */
function encodeSymbol(symbol: number) {
return symbol < 10 ? String(symbol) : encodeSymbolLookupTable[symbol];
}

/** @internal */
function pad(value: string, constLength: number) {
return (
Array(constLength - value.length)
.fill('0')
.join('') + value
);
}

/** @internal */
export function uintToBase32StringMapper(num: number, constLength: number | undefined = undefined) {
if (num === 0) return pad('0', constLength ?? 1);

let base32Str = '',
remaining = num,
symbolsLeft = Math.floor(getBaseLog(32, num)) + 1;
while (symbolsLeft > 0) {
const val = Math.pow(32, symbolsLeft - 1);
const symbol = Math.floor(remaining / val);

base32Str += encodeSymbol(symbol);

remaining -= symbol * val;
symbolsLeft--;
}

return pad(base32Str, constLength ?? base32Str.length);
}

/** @internal */
export function paddedUintToBase32StringMapper(constLength: number) {
return (num: number) => uintToBase32StringMapper(num, constLength);
}

/** @internal */
const Base32Regex = /^[0-9A-HJKMNP-TV-Z]+$/;

/** @internal */
export function uintToBase32StringUnmapper(value: unknown) {
if (typeof value !== 'string') {
throw new Error('Unsupported type');
}

const normalizedBase32str = value.toUpperCase();
if (!Base32Regex.test(normalizedBase32str)) {
throw new Error('Unsupported type');
}

const symbols = normalizedBase32str.split('').map((char) => decodeSymbolLookupTable[char]);

return symbols.reduce((prev, curr, i) => prev + curr * Math.pow(32, symbols.length - 1 - i), 0);
}
43 changes: 43 additions & 0 deletions packages/fast-check/src/arbitrary/ulid.ts
@@ -0,0 +1,43 @@
import { Arbitrary } from '../check/arbitrary/definition/Arbitrary';
import { tuple } from './tuple';
import { integer } from './integer';
import { paddedUintToBase32StringMapper, uintToBase32StringUnmapper } from './_internals/mappers/UintToBase32String';

/**
* For ulid
*
* According to {@link https://github.com/ulid/spec | ulid spec}
*
* No mixed case, only upper case digits (0-9A-Z except for: I,L,O,U)
*
* @remarks Since 3.11.0
* @public
*/
export function ulid(): Arbitrary<string> {
const timestampPartArbitrary = integer({ min: 0, max: 0xffffffffffff }); // 48 bits
// Numeric literals with absolute values equal to 2^53 or greater are too large to be represented accurately as integers.
// Therefore we split the 80 bit randomness part into two integers of 40 bits length each.
const randomnessPartOneArbitrary = integer({ min: 0, max: 0xffffffffff }); // 40 bits
const randomnessPartTwoArbitrary = integer({ min: 0, max: 0xffffffffff }); // 40 bits

return tuple(timestampPartArbitrary, randomnessPartOneArbitrary, randomnessPartTwoArbitrary).map(
([date, random1, random2]) => {
return [
paddedUintToBase32StringMapper(10)(date), // 10 chars of base32 -> 48 bits
paddedUintToBase32StringMapper(8)(random1), // 8 chars of base32 -> 40 bits
paddedUintToBase32StringMapper(8)(random2),
].join('');
},
(value) => {
if (typeof value !== 'string' || value.length !== 26) {
throw new Error('Unsupported type');
}

return [value.slice(0, 10), value.slice(10, 18), value.slice(18)].map(uintToBase32StringUnmapper) as [
number,
number,
number
];
}
);
}
@@ -0,0 +1,19 @@
import fc from 'fast-check';
import {
paddedUintToBase32StringMapper,
uintToBase32StringUnmapper,
} from '../../../../../src/arbitrary/_internals/mappers/UintToBase32String';

describe('uintToBase32StringUnmapper', () => {
it('is able to unmap any mapped value', () =>
fc.assert(
fc.property(fc.integer({ min: 0, max: 0xffffffffffff }), (input) => {
// Arrange
const mapped = paddedUintToBase32StringMapper(10)(input);
// Act
const out = uintToBase32StringUnmapper(mapped);
// Assert
expect(out).toEqual(input);
})
));
});
79 changes: 79 additions & 0 deletions packages/fast-check/test/unit/arbitrary/ulid.spec.ts
@@ -0,0 +1,79 @@
import fc from 'fast-check';
import { ulid } from '../../../src/arbitrary/ulid';
import { fakeArbitraryStaticValue } from './__test-helpers__/ArbitraryHelpers';

import * as _IntegerMock from '../../../src/arbitrary/integer';
import { Arbitrary } from '../../../src/check/arbitrary/definition/Arbitrary';
import { fakeRandom } from './__test-helpers__/RandomHelpers';
import {
assertProduceSameValueGivenSameSeed,
assertProduceCorrectValues,
assertProduceValuesShrinkableWithoutContext,
assertShrinkProducesSameValueWithoutInitialContext,
} from './__test-helpers__/ArbitraryAssertions';
const IntegerMock: { integer: (ct: { min: number; max: number }) => Arbitrary<number> } = _IntegerMock;

function beforeEachHook() {
jest.resetModules();
jest.restoreAllMocks();
fc.configureGlobal({ beforeEach: beforeEachHook });
}
beforeEach(beforeEachHook);

describe('ulid', () => {
it('should produce the minimal ulid given all minimal generated values', () => {
// Arrange
const { instance: mrng } = fakeRandom();
const integer = jest.spyOn(IntegerMock, 'integer');
integer.mockImplementation(({ min }) => {
const { instance } = fakeArbitraryStaticValue(() => min);
return instance;
});

// Act
const arb = ulid();
const out = arb.generate(mrng, undefined);

// Assert
expect(out.value).toBe('0'.repeat(26));
});

it('should produce the maximal ulid given all maximal generated values', () => {
// Arrange
const { instance: mrng } = fakeRandom();
const integer = jest.spyOn(IntegerMock, 'integer');
integer.mockImplementation(({ max }) => {
const { instance } = fakeArbitraryStaticValue(() => max);
return instance;
});

// Act
const arb = ulid();
const out = arb.generate(mrng, undefined);

// Assert
expect(out.value).toBe('7ZZZZZZZZZZZZZZZZZZZZZZZZZ');
});
});

describe('uuid (integration)', () => {
const isCorrect = (u: string) => {
expect(u).toMatch(/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/);
};

it('should produce the same values given the same seed', () => {
assertProduceSameValueGivenSameSeed(ulid);
});

it('should only produce correct values', () => {
assertProduceCorrectValues(ulid, isCorrect);
});

it('should produce values seen as shrinkable without any context', () => {
assertProduceValuesShrinkableWithoutContext(ulid);
});

it('should be able to shrink to the same values without initial context', () => {
assertShrinkProducesSameValueWithoutInitialContext(ulid);
});
});

0 comments on commit 72e62cc

Please sign in to comment.