211 lines
6.7 KiB
TypeScript
211 lines
6.7 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import {
|
||
normalizeWordList,
|
||
passphrase,
|
||
passphraseWithWordList,
|
||
randomIdentifiers,
|
||
randomIntegers,
|
||
randomString,
|
||
rollDice,
|
||
ulid,
|
||
uuidV4,
|
||
uuidV7,
|
||
} from "../../src/random/generators";
|
||
import {
|
||
coinFlips,
|
||
dealCards,
|
||
decimalFractions,
|
||
integerSequence,
|
||
randomCoordinates,
|
||
randomDates,
|
||
} from "../../src/random/draws";
|
||
import { randomSource } from "../../src/random/source";
|
||
|
||
describe("random generators", () => {
|
||
it("repeats deterministic recipes", () =>
|
||
expect(
|
||
randomIntegers(randomSource("deterministic", "seed"), 10, 1, 6),
|
||
).toEqual(randomIntegers(randomSource("deterministic", "seed"), 10, 1, 6)));
|
||
it("produces RFC-shaped UUIDs", () => {
|
||
const source = randomSource("deterministic", "id");
|
||
expect(uuidV4(source)).toMatch(
|
||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u,
|
||
);
|
||
expect(uuidV7(source, 0)).toMatch(
|
||
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u,
|
||
);
|
||
});
|
||
it("creates a 26-character ULID", () =>
|
||
expect(ulid(randomSource("deterministic", "id"), 0)).toMatch(
|
||
/^[0-9A-HJKMNP-TV-Z]{26}$/u,
|
||
));
|
||
it("bounds identifier batches before allocating their output array", () => {
|
||
const source = randomSource("deterministic", "identifier-bounds");
|
||
expect(() => randomIdentifiers(source, 0, "uuid4", 0)).toThrow(
|
||
/safe integer from 1–100,000/u,
|
||
);
|
||
expect(() => randomIdentifiers(source, 100_001, "uuid7", 0)).toThrow(
|
||
/safe integer from 1–100,000/u,
|
||
);
|
||
expect(() => randomIdentifiers(source, Number.NaN, "ulid", 0)).toThrow(
|
||
/safe integer from 1–100,000/u,
|
||
);
|
||
expect(randomIdentifiers(source, 2, "uuid4", 0)).toHaveLength(2);
|
||
});
|
||
it("rejects an inclusive MAX_SAFE_INTEGER bound before adding one", () => {
|
||
const source = randomSource("deterministic", "integer-boundary");
|
||
expect(() =>
|
||
randomIntegers(
|
||
source,
|
||
1,
|
||
Number.MAX_SAFE_INTEGER,
|
||
Number.MAX_SAFE_INTEGER,
|
||
),
|
||
).toThrow(/less than Number\.MAX_SAFE_INTEGER/u);
|
||
expect(
|
||
randomIntegers(
|
||
source,
|
||
1,
|
||
Number.MAX_SAFE_INTEGER - 1,
|
||
Number.MAX_SAFE_INTEGER - 1,
|
||
),
|
||
).toEqual([Number.MAX_SAFE_INTEGER - 1]);
|
||
});
|
||
it("rejects biased duplicate alphabets", () =>
|
||
expect(() =>
|
||
randomString(randomSource("deterministic", "x"), 3, "aab"),
|
||
).toThrow(/duplicate/u));
|
||
it("bounds dice and reports the modifier", () =>
|
||
expect(
|
||
rollDice(randomSource("deterministic", "dice"), "4d6+2"),
|
||
).toMatchObject({ expression: "4d6+2", modifier: 2 }));
|
||
it("reports only the uniform-list entropy model", () =>
|
||
expect(
|
||
passphrase(randomSource("deterministic", "words"), 4, [
|
||
"one",
|
||
"two",
|
||
"three",
|
||
"four",
|
||
]).entropy,
|
||
).toBe(8));
|
||
it("normalizes a custom word list once and reports its eligible count", () => {
|
||
const words = normalizeWordList(" one \r\n\r\ntwo\n three ");
|
||
expect(words).toMatchObject({
|
||
words: ["one", "two", "three"],
|
||
canonical: "one\ntwo\nthree",
|
||
normalization: "trim-lines-drop-empty-preserve-order-v1",
|
||
});
|
||
expect(
|
||
passphraseWithWordList(
|
||
randomSource("deterministic", "normalized-words"),
|
||
4,
|
||
words,
|
||
),
|
||
).toMatchObject({ listSize: 3, entropy: 4 * Math.log2(3) });
|
||
});
|
||
it("bounds alphabet and word-list allocation before generation", () => {
|
||
const source = randomSource("deterministic", "bounds");
|
||
expect(() => randomString(source, 1, "ab".repeat(70_000))).toThrow(
|
||
/131,072/u,
|
||
);
|
||
expect(() => passphrase(source, 1, ["x".repeat(10_001), "y"])).toThrow(
|
||
/10,000/u,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("local draws", () => {
|
||
it("reproduces coin flips and reports only binary outcomes", () => {
|
||
const first = coinFlips(randomSource("deterministic", "coins"), 50);
|
||
const second = coinFlips(randomSource("deterministic", "coins"), 50);
|
||
expect(first).toEqual(second);
|
||
expect(first.every((value) => value === "Heads" || value === "Tails")).toBe(
|
||
true,
|
||
);
|
||
});
|
||
|
||
it("deals every card from a standard deck without replacement", () => {
|
||
const cards = dealCards(randomSource("deterministic", "cards"), 1, 52);
|
||
expect(cards).toHaveLength(52);
|
||
expect(new Set(cards).size).toBe(52);
|
||
expect(
|
||
cards.every((card) => /^(?:A|[2-9]|10|J|Q|K)[♠♥♦♣]$/u.test(card)),
|
||
).toBe(true);
|
||
});
|
||
|
||
it("shuffles an inclusive integer range exactly once", () => {
|
||
const values = integerSequence(
|
||
randomSource("deterministic", "sequence"),
|
||
-2,
|
||
3,
|
||
);
|
||
expect(values).toHaveLength(6);
|
||
expect([...values].sort((a, b) => a - b)).toEqual([-2, -1, 0, 1, 2, 3]);
|
||
});
|
||
|
||
it("filters dates by weekday and can sample without replacement", () => {
|
||
const values = randomDates(
|
||
randomSource("deterministic", "dates"),
|
||
4,
|
||
"2024-02-01",
|
||
"2024-02-29",
|
||
{ weekdays: [1], withoutReplacement: true },
|
||
);
|
||
expect(values).toHaveLength(4);
|
||
expect(new Set(values).size).toBe(4);
|
||
expect(
|
||
values.every((value) => new Date(`${value}T00:00:00Z`).getUTCDay() === 1),
|
||
).toBe(true);
|
||
expect(() =>
|
||
randomDates(
|
||
randomSource("deterministic", "bad-date"),
|
||
1,
|
||
"2023-02-29",
|
||
"2023-03-01",
|
||
),
|
||
).toThrow(/valid Gregorian/u);
|
||
});
|
||
|
||
it("generates exact-length decimal fractions with no binary formatting", () => {
|
||
const values = decimalFractions(
|
||
randomSource("deterministic", "fractions"),
|
||
4,
|
||
12,
|
||
);
|
||
expect(values).toHaveLength(4);
|
||
expect(values.every((value) => /^0\.\d{12}$/u.test(value))).toBe(true);
|
||
});
|
||
|
||
it("generates reproducible coordinates inside geographic bounds", () => {
|
||
const first = randomCoordinates(
|
||
randomSource("deterministic", "coordinates"),
|
||
100,
|
||
6,
|
||
);
|
||
const second = randomCoordinates(
|
||
randomSource("deterministic", "coordinates"),
|
||
100,
|
||
6,
|
||
);
|
||
expect(first).toEqual(second);
|
||
expect(
|
||
first.every(
|
||
({ latitude, longitude }) =>
|
||
latitude >= -90 &&
|
||
latitude <= 90 &&
|
||
longitude >= -180 &&
|
||
longitude <= 180,
|
||
),
|
||
).toBe(true);
|
||
});
|
||
|
||
it("rejects draw requests beyond their allocation bounds", () => {
|
||
const source = randomSource("deterministic", "bounds");
|
||
expect(() => coinFlips(source, 100_001)).toThrow(/100,000/u);
|
||
expect(() => dealCards(source, 1, 53)).toThrow(/1–52/u);
|
||
expect(() => integerSequence(source, 0, 100_000)).toThrow(/100,000/u);
|
||
expect(() => decimalFractions(source, 100_000, 64)).toThrow(/4,000,000/u);
|
||
expect(() => randomCoordinates(source, 1, 11)).toThrow(/0–10/u);
|
||
});
|
||
});
|