Skip to content

Commit

Permalink
✨ Implement arbitrary for ulid (#4020)
Browse files Browse the repository at this point in the history
  • Loading branch information
vecerek committed Jul 4, 2023
1 parent 2fad2c1 commit d7b97d9
Show file tree
Hide file tree
Showing 9 changed files with 319 additions and 0 deletions.
8 changes: 8 additions & 0 deletions .yarn/versions/bccaa5bb.yml
@@ -0,0 +1,8 @@
releases:
fast-check: minor

declined:
- "@fast-check/ava"
- "@fast-check/jest"
- "@fast-check/vitest"
- "@fast-check/worker"
@@ -0,0 +1,122 @@
/** @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): string {
if (num === 0) return pad('0', constLength ?? 1);

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

base32Str += encodeSymbol(symbol);
remaining -= symbol * val;
}

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

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

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

/** @internal */
export function uintToBase32StringUnmapper(value: unknown): number {
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
];
}
);
}
2 changes: 2 additions & 0 deletions packages/fast-check/src/fast-check-default.ts
Expand Up @@ -95,6 +95,7 @@ import { unicodeString } from './arbitrary/unicodeString';
import { subarray, SubarrayConstraints } from './arbitrary/subarray';
import { shuffledSubarray, ShuffledSubarrayConstraints } from './arbitrary/shuffledSubarray';
import { tuple } from './arbitrary/tuple';
import { ulid } from './arbitrary/ulid';
import { uuid } from './arbitrary/uuid';
import { uuidV } from './arbitrary/uuidV';
import { webAuthority, WebAuthorityConstraints } from './arbitrary/webAuthority';
Expand Down Expand Up @@ -302,6 +303,7 @@ export {
webQueryParameters,
webUrl,
emailAddress,
ulid,
uuid,
uuidV,
int8Array,
Expand Down
8 changes: 8 additions & 0 deletions packages/fast-check/test/e2e/NoRegression.spec.ts
Expand Up @@ -582,6 +582,14 @@ describe(`NoRegression`, () => {
)
).toThrowErrorMatchingSnapshot();
});
it('ulid', () => {
expect(() =>
fc.assert(
fc.property(fc.ulid(), (v) => testFunc(v)),
settings
)
).toThrowErrorMatchingSnapshot();
});
it('uuid', () => {
expect(() =>
fc.assert(
Expand Down
Expand Up @@ -4214,6 +4214,20 @@ Execution summary:
. . . . . . . . . . . . . . . . . . . . √ [Uint32Array.from([305109898])]"
`;
exports[`NoRegression ulid 1`] = `
"Property failed after 1 tests
{ seed: 42, path: "0:0:0:0", endOnFailure: true }
Counterexample: ["00000000000000000000000000"]
Shrunk 3 time(s)
Got error: Property failed by returning false
Execution summary:
× ["7ZZZZZZZZDZ0AWTX6YQCVVJ1XW"]
. × ["0000000000Z0AWTX6YQCVVJ1XW"]
. . × ["000000000000000000QCVVJ1XW"]
. . . × ["00000000000000000000000000"]"
`;
exports[`NoRegression unicodeJson 1`] = `
"Property failed after 2 tests
{ seed: 42, path: "1:1:0:1:3:3:4:3:3:3:4:3:3:3:3:4:4:5:3:3:3:3:3:3:4:3:3:3:3:3:4:3:6:4:3:6:3:4:3:3:5:4:3:6:4", endOnFailure: true }
Expand Down
@@ -0,0 +1,19 @@
import fc from 'fast-check';
import {
paddedUintToBase32StringMapper,
uintToBase32StringUnmapper,
} from '../../../../../src/arbitrary/_internals/mappers/UintToBase32String';

describe('uintToBase32StringUnmapper', () => {
it('should be able to unmap any mapped value', () =>
fc.assert(
fc.property(fc.integer({ min: 0 }), (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);
});
});
24 changes: 24 additions & 0 deletions website/docs/core-blocks/arbitraries/fake-data/identifier.md
Expand Up @@ -6,6 +6,30 @@ slug: /core-blocks/arbitraries/fake-data/identifier/

Generate identifier values.

### ulid

ULID values.

**Signatures:**

- `fc.ulid()`

**Usages:**

```js
fc.ulid();
// Examples of generated values:
// • "7AVDFZJAXCM0F25E3SZZZZZZYZ"
// • "7ZZZZZZZYP5XN60H51ZZZZZZZP"
// • "2VXXEMQ2HWRSNWMP9PZZZZZZZA"
// • "15RQ23H1M8YB80EVPD2EG8W7K1"
// • "6QV4RKC7C8ZZZZZZZFSF7PWQF5"
// • …
```

Resources: [API reference](https://fast-check.dev/api-reference/functions/ulid.html).
Available since 3.11.0.

### uuid

UUID values including versions 1 to 5.
Expand Down

0 comments on commit d7b97d9

Please sign in to comment.